Recipe Studio local model selector (#5769)

* feat(recipes): round-trip local model variants

* feat(recipes): add local model selector

* feat(recipes): wire selector into model editors

* fix(recipes): clear stale model state on relink

* feat(recipes): load selected local models for jobs

* chore(frontend): simplify biome scripts

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(recipes): handle local selector edge cases

* fix(recipes): polish local model selector behavior

* fix(recipes): delay local model restore until terminal runs

* fix(recipes): accept resolved default gguf variants

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-05-26 11:37:24 +02:00 committed by GitHub
commit 31ac558a73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1711 additions and 347 deletions

View file

@ -109,7 +109,7 @@ def _apply_data_designer_image_context_patch() -> None:
return
try:
from data_designer.config.models import ImageContext
from data_designer.config.models import ImageContext # pyright: ignore[reportMissingImports]
except ImportError:
return
@ -131,7 +131,7 @@ def _apply_data_designer_image_context_patch() -> None:
def build_model_providers(recipe: dict[str, Any]):
from data_designer.config.models import ModelProvider
from data_designer.config.models import ModelProvider # pyright: ignore[reportMissingImports]
providers: list[ModelProvider] = []
for provider in recipe.get("model_providers", []):
@ -174,7 +174,7 @@ def _validate_recipe_runtime_support(
def build_mcp_providers(
recipe: dict[str, Any],
) -> list:
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider # pyright: ignore[reportMissingImports]
providers: list[MCPProvider | LocalStdioMCPProvider] = []
for provider in recipe.get("mcp_providers", []):
@ -214,16 +214,42 @@ def build_mcp_providers(
return providers
def _strip_frontend_model_config_metadata(recipe: dict[str, Any]) -> dict[str, Any]:
model_configs = recipe.get("model_configs")
if not isinstance(model_configs, list):
return recipe
changed = False
next_model_configs: list[Any] = []
for model_config in model_configs:
if isinstance(model_config, dict) and "gguf_variant" in model_config:
next_model_config = dict(model_config)
next_model_config.pop("gguf_variant", None)
next_model_configs.append(next_model_config)
changed = True
continue
next_model_configs.append(model_config)
if not changed:
return recipe
return {
**recipe,
"model_configs": next_model_configs,
}
def build_config_builder(recipe: dict[str, Any]):
_apply_data_designer_image_context_patch()
from data_designer.config import DataDesignerConfigBuilder
from data_designer.config.processors import ProcessorType
from data_designer.config import DataDesignerConfigBuilder # pyright: ignore[reportMissingImports]
from data_designer.config.processors import ProcessorType # pyright: ignore[reportMissingImports]
recipe_core = {
key: value
for key, value in recipe.items()
if key not in {"model_providers", "mcp_providers"}
}
recipe_core = _strip_frontend_model_config_metadata(recipe_core)
recipe_core, oxc_local_callable_specs = split_oxc_local_callable_validators(
recipe_core
)
@ -256,8 +282,9 @@ def create_data_designer(
artifact_path: str | None = None,
):
_apply_data_designer_image_context_patch()
from data_designer.interface.data_designer import DataDesigner
from data_designer.interface.data_designer import DataDesigner # pyright: ignore[reportMissingImports]
recipe = _strip_frontend_model_config_metadata(recipe)
model_providers = build_model_providers(recipe)
_validate_recipe_runtime_support(recipe, model_providers)
@ -265,7 +292,7 @@ def create_data_designer(
# when the pipeline contains no LLM columns. Supply a lightweight stub
# so sampler/expression-only recipes can run without a real provider.
if not model_providers:
from data_designer.config.models import ModelProvider
from data_designer.config.models import ModelProvider # pyright: ignore[reportMissingImports]
model_providers = [
ModelProvider(

View file

@ -299,7 +299,11 @@ class InferenceStatusResponse(BaseModel):
"""Current inference backend status"""
active_model: Optional[str] = Field(
None, description = "Currently active model identifier"
None, description = "Currently active model display identifier"
)
model_identifier: Optional[str] = Field(
None,
description = "Loadable identifier for the active model.",
)
is_vision: bool = Field(
False, description = "Whether the active model is a vision model"

View file

@ -95,6 +95,89 @@ def _used_llm_model_aliases(recipe: dict[str, Any]) -> set[str]:
return aliases
def _used_local_model_selections(
recipe: dict[str, Any], local_provider_names: set[str]
) -> dict[tuple[str, str], list[str]]:
used_aliases = _used_llm_model_aliases(recipe)
selections: dict[tuple[str, str], list[str]] = {}
for mc in recipe.get("model_configs", []):
if not isinstance(mc, dict):
continue
alias = mc.get("alias")
if not isinstance(alias, str) or alias not in used_aliases:
continue
provider = mc.get("provider")
if not isinstance(provider, str) or provider not in local_provider_names:
continue
model = mc.get("model")
target = model.strip() if isinstance(model, str) else ""
if not target or target.lower() == "local":
continue
variant = mc.get("gguf_variant")
gguf_variant = variant.strip() if isinstance(variant, str) else ""
selections.setdefault((target, gguf_variant), []).append(alias)
return selections
def _single_used_local_model_selection(
recipe: dict[str, Any], local_provider_names: set[str]
) -> tuple[str, str] | None:
selections = _used_local_model_selections(recipe, local_provider_names)
if not selections:
return None
if len(selections) > 1:
aliases = ", ".join(alias for values in selections.values() for alias in values)
raise ValueError(
"Recipes supports one active local model per run. "
f"Select the same local model and GGUF variant for: {aliases}."
)
return next(iter(selections))
def _loaded_local_model_identity() -> tuple[bool, str, str]:
from routes.inference import get_llama_cpp_backend
from core.inference import get_inference_backend
llama = get_llama_cpp_backend()
if llama.is_loaded:
model = str(getattr(llama, "model_identifier", "") or "").strip()
variant = str(getattr(llama, "hf_variant", "") or "").strip()
return True, model, variant
backend = get_inference_backend()
active_model = str(getattr(backend, "active_model_name", "") or "").strip()
if active_model:
return True, active_model, ""
return False, "", ""
def _ensure_selected_local_model_loaded(
recipe: dict[str, Any], local_provider_names: set[str]
) -> None:
model_loaded, active_model, active_variant = _loaded_local_model_identity()
if not model_loaded:
raise ValueError(
"No model loaded in Chat. Load a model first, then run the recipe."
)
selection = _single_used_local_model_selection(recipe, local_provider_names)
if selection is None:
return
target, gguf_variant = selection
variant_matches = not gguf_variant or active_variant == gguf_variant
if active_model.lower() != target.lower() or not variant_matches:
selected = f"{target} ({gguf_variant})" if gguf_variant else target
active = (
f"{active_model} ({active_variant})" if active_variant else active_model
)
raise ValueError(
"Selected local model is not loaded. "
f"Selected {selected}; active {active or 'none'}. "
"Load the selected model again, then run the recipe."
)
def _inject_local_structured_response_format(
recipe: dict[str, Any], local_provider_names: set[str]
) -> None:
@ -238,24 +321,12 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
token = ""
internal_key_id: Optional[int] = None
if local_names & referenced_providers:
# Verify a model is loaded.
# NOTE: This is a point-in-time check (TOCTOU). The model could be unloaded
# or swapped after this check but before the recipe subprocess calls /v1.
# The inference endpoint returns a clear 400 in that case.
#
# Imports are deferred to avoid circular dependencies with inference modules.
from routes.inference import get_llama_cpp_backend
from core.inference import get_inference_backend
llama = get_llama_cpp_backend()
model_loaded = llama.is_loaded
if not model_loaded:
backend = get_inference_backend()
model_loaded = bool(backend.active_model_name)
if not model_loaded:
raise ValueError(
"No model loaded in Chat. Load a model first, then run the recipe."
)
# Verify the selected local model is loaded before minting a workflow
# key. This still remains a point-in-time singleton-backend check
# (TOCTOU): a future generation token should bind frontend load and
# job creation, and the inference endpoint returns a clear 400 if the
# model is later unloaded or swapped before the subprocess calls /v1.
_ensure_selected_local_model_loaded(recipe, local_names)
from auth import storage # deferred: avoids circular import
@ -287,12 +358,12 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
providers[i].pop("extra_body", None)
# Force skip_health_check on any model_config that references a local
# provider. The local /v1/models endpoint only lists the real loaded
# model (e.g. "unsloth/llama-3.2-1b") and not the placeholder "local"
# that the recipe sends as the model id, so data_designer's pre-flight
# health check would otherwise fail before the first completion call.
# The backend route ignores the model id field in chat completions, so
# skipping the check is safe.
# provider. The frontend now sends the explicit selected local model id,
# but llama-server's /v1/models response can still differ from that id
# for local paths, cache aliases, and GGUF variant loads. The recipe run
# has already gated on a loaded local inference backend above, so the
# data_designer model-list health check would be redundant and can reject
# valid local selections.
for mc in recipe.get("model_configs", []):
if not isinstance(mc, dict):
continue
@ -319,7 +390,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
tpl_kwargs = extra_body.get("chat_template_kwargs")
if not isinstance(tpl_kwargs, dict):
tpl_kwargs = {}
tpl_kwargs.setdefault("enable_thinking", False)
tpl_kwargs["enable_thinking"] = False
extra_body["chat_template_kwargs"] = tpl_kwargs
params["extra_body"] = extra_body

View file

@ -606,11 +606,16 @@ async def load_model(
backend = get_inference_backend()
llama_backend = get_llama_cpp_backend()
if request.gguf_variant:
is_direct_gguf_request = model_identifier.lower().endswith(".gguf")
if request.gguf_variant or is_direct_gguf_request:
gguf_variant_matches = is_direct_gguf_request or bool(
llama_backend.hf_variant
and request.gguf_variant
and llama_backend.hf_variant.lower() == request.gguf_variant.lower()
)
if (
llama_backend.is_loaded
and llama_backend.hf_variant
and llama_backend.hf_variant.lower() == request.gguf_variant.lower()
and gguf_variant_matches
and llama_backend.model_identifier
and llama_backend.model_identifier.lower() == model_identifier.lower()
# Match runtime settings too so Apply isn't dropped (#5401).
@ -619,7 +624,8 @@ async def load_model(
and getattr(llama_backend, "_audio_probed", True)
):
logger.info(
f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
"Model already loaded (GGUF): "
f"{model_log_label} variant={request.gguf_variant or llama_backend.hf_variant}, skipping reload"
)
inference_config = load_inference_config(llama_backend.model_identifier)
@ -1373,6 +1379,7 @@ async def get_status(
_audio_type = getattr(llama_backend, "_audio_type", None)
return InferenceStatusResponse(
active_model = _display_model_id,
model_identifier = None if _native_grant_backed else _model_id,
is_vision = llama_backend.is_vision,
is_gguf = True,
gguf_variant = llama_backend.hf_variant,
@ -1435,6 +1442,7 @@ async def get_status(
return InferenceStatusResponse(
active_model = backend.active_model_name,
model_identifier = backend.active_model_name,
is_vision = is_vision,
is_gguf = False,
is_audio = is_audio,

View file

@ -12,8 +12,8 @@
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"biome:check": "biome check .",
"biome:fix": "biome check . --write"
"biome:check": "biome check",
"biome:fix": "biome check --write"
},
"dependencies": {
"@assistant-ui/core": "0.1.17",

View file

@ -2,6 +2,14 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ChatPage } from "./chat-page";
export {
getInferenceStatus,
listGgufVariants,
listLocalModels,
loadModel,
type LocalModelInfo,
} from "./api/chat-api";
export type { GgufVariantDetail } from "./types/api";
export {
ChatSettingsPanel,
defaultInferenceParams,

View file

@ -248,7 +248,7 @@ interface BackendInferenceDefaults {
export interface BackendInferenceEnvelope {
is_gguf?: boolean;
context_length?: number | null;
inference?: BackendInferenceDefaults;
inference?: BackendInferenceDefaults | null;
}
export function mergeBackendRecommendedInference({

View file

@ -143,6 +143,7 @@ export interface UnloadModelRequest {
export interface InferenceStatusResponse {
active_model: string | null;
model_identifier?: string | null;
is_vision: boolean;
is_gguf?: boolean;
gguf_variant?: string | null;
@ -158,7 +159,7 @@ export interface InferenceStatusResponse {
min_p?: number;
presence_penalty?: number;
trust_remote_code?: boolean;
};
} | null;
requires_trust_remote_code?: boolean;
supports_reasoning?: boolean;
reasoning_style?: "enable_thinking" | "reasoning_effort";

View file

@ -3,6 +3,7 @@
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import { LocalRecipeModelSelector } from "../../dialogs/models/local-recipe-model-selector";
import type { ModelConfig, ModelProviderConfig } from "../../types";
import { InlineField } from "./inline-field";
@ -32,7 +33,9 @@ export function InlineModel(props: InlineModelProps): ReactElement {
className="nodrag h-8 w-full text-xs"
placeholder="https://api.example.com/v1"
value={props.config.endpoint}
onChange={(event) => props.onUpdate({ endpoint: event.target.value })}
onChange={(event) =>
props.onUpdate({ endpoint: event.target.value })
}
/>
</InlineField>
<InlineField label="API key">
@ -53,23 +56,32 @@ export function InlineModel(props: InlineModelProps): ReactElement {
}
// model_config branch - mirror the local-aware provider sync from the
// dialog path so inline edits do not leave stale "local" placeholders
// on external providers and fill the placeholder when switching to local.
// dialog path so inline edits clear stale local-only metadata without
// synthesizing the legacy "local" placeholder.
const localNames = props.localProviderNames ?? new Set<string>();
const modelConfig = props.config;
const handleProviderChange = (nextProvider: string) => {
const isLocal = localNames.has(nextProvider);
if (isLocal && !modelConfig.model.trim()) {
props.onUpdate({ provider: nextProvider, model: "local" });
return;
}
if (!isLocal && modelConfig.model === "local") {
props.onUpdate({ provider: nextProvider, model: "" });
return;
}
props.onUpdate({ provider: nextProvider });
};
const isLinkedToLocal = localNames.has(modelConfig.provider);
const handleProviderChange = (nextProvider: string) => {
const nextIsLocal = localNames.has(nextProvider);
if (isLinkedToLocal !== nextIsLocal) {
props.onUpdate({
provider: nextProvider,
model: "",
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
});
return;
}
props.onUpdate({
provider: nextProvider,
...(nextIsLocal
? {}
: {
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
}),
});
};
return (
<div className="grid gap-3 sm:grid-cols-2">
@ -82,12 +94,38 @@ export function InlineModel(props: InlineModelProps): ReactElement {
/>
</InlineField>
<InlineField label="Model">
<Input
className="nodrag h-8 w-full text-xs"
placeholder={isLinkedToLocal ? "local" : "gpt-4o-mini"}
value={modelConfig.model}
onChange={(event) => props.onUpdate({ model: event.target.value })}
/>
{isLinkedToLocal ? (
<LocalRecipeModelSelector
compact={true}
className="h-8 rounded-md text-xs"
value={
modelConfig.model.trim().toLowerCase() === "local"
? ""
: modelConfig.model
}
ggufVariant={modelConfig.gguf_variant}
onChange={(model, variant) =>
props.onUpdate({
model,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: variant ?? undefined,
})
}
/>
) : (
<Input
className="nodrag h-8 w-full text-xs"
placeholder="gpt-4o-mini"
value={modelConfig.model}
onChange={(event) =>
props.onUpdate({
model: event.target.value,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
})
}
/>
)}
</InlineField>
<InlineField label="Temperature" className="sm:col-span-2">
<Input

View file

@ -0,0 +1,644 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import {
type GgufVariantDetail,
type LocalModelInfo,
listGgufVariants,
listLocalModels,
} from "@/features/chat";
import { cn } from "@/lib/utils";
import { Link } from "@tanstack/react-router";
import { ChevronDownIcon, ChevronRightIcon, RefreshCwIcon } from "lucide-react";
import {
type ComponentPropsWithoutRef,
type ReactElement,
forwardRef,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
const GGUF_SUFFIX_PATTERN = /-GGUF(?:$|-)/i;
type LocalRecipeModelSelectorProps = {
value: string;
ggufVariant?: string | null;
onChange: (modelId: string, ggufVariant?: string | null) => void;
inputId?: string;
disabled?: boolean;
compact?: boolean;
className?: string;
};
function normalizeForSearch(value: string): string {
return value.toLowerCase().replace(/[\s_.-]/g, "");
}
function hasGgufSuffix(value: string | null | undefined): boolean {
return GGUF_SUFFIX_PATTERN.test(value ?? "");
}
function getModelLabel(model: LocalModelInfo): string {
return model.model_id?.trim() || model.display_name || model.id;
}
function isDirectGguf(model: LocalModelInfo): boolean {
return model.path.toLowerCase().endsWith(".gguf");
}
function isExpandableGguf(model: LocalModelInfo): boolean {
return (
!isDirectGguf(model) &&
(hasGgufSuffix(model.id) ||
hasGgufSuffix(model.display_name) ||
hasGgufSuffix(model.model_id))
);
}
function sourceLabel(model: LocalModelInfo): string {
switch (model.source) {
case "models_dir":
return "Models";
case "hf_cache":
return "HF cache";
case "lmstudio":
return "LM Studio";
case "custom":
return "Custom folder";
default:
return "Local";
}
}
type SelectedModelSummary = {
label: string;
source: string;
isGguf: boolean;
};
function getSelectedModelSummary(
value: string,
selectedModel: LocalModelInfo | null,
ggufVariant?: string | null,
): SelectedModelSummary {
if (!selectedModel) {
return {
label: value,
source: "Local model",
isGguf: Boolean(ggufVariant),
};
}
return {
label: getModelLabel(selectedModel),
source: sourceLabel(selectedModel),
isGguf: isDirectGguf(selectedModel) || isExpandableGguf(selectedModel),
};
}
function LocalGgufVariantList({
repoId,
selectedVariant,
onSelect,
}: {
repoId: string;
selectedVariant?: string | null;
onSelect: (variant: string) => void;
}): ReactElement {
const [variants, setVariants] = useState<GgufVariantDetail[] | null>(null);
const [defaultVariant, setDefaultVariant] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
listGgufVariants(repoId)
.then((response) => {
if (cancelled) {
return;
}
setVariants(response.variants);
setDefaultVariant(response.default_variant);
})
.catch((err) => {
if (cancelled) {
return;
}
setError(
err instanceof Error ? err.message : "Failed to load variants.",
);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [repoId]);
const sortedVariants = useMemo(() => {
if (!variants) {
return null;
}
return [...variants].sort((a, b) => {
if (a.quant === defaultVariant) {
return -1;
}
if (b.quant === defaultVariant) {
return 1;
}
if (a.downloaded !== b.downloaded) {
return a.downloaded ? -1 : 1;
}
return a.quant.localeCompare(b.quant);
});
}, [defaultVariant, variants]);
if (loading) {
return (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground">
<Spinner className="size-3" />
Loading quantizations...
</div>
);
}
if (error) {
return <div className="px-4 py-2 text-xs text-destructive">{error}</div>;
}
if (!sortedVariants || sortedVariants.length === 0) {
return (
<div className="px-4 py-2 text-xs text-muted-foreground">
No GGUF quantizations found for this model.
</div>
);
}
return (
<div className="ml-6 mt-1 rounded-lg bg-muted/25 p-1.5">
<div className="mb-1 px-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Quantization
</div>
<div className="space-y-0.5">
{sortedVariants.map((variant) => {
const selected = selectedVariant === variant.quant;
return (
<button
key={variant.filename}
type="button"
onClick={() => onSelect(variant.quant)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60",
selected && "bg-background text-foreground shadow-sm",
)}
>
<span className="min-w-0 flex-1 truncate font-mono">
{variant.quant}
</span>
{variant.quant === defaultVariant ? (
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
recommended
</Badge>
) : null}
{variant.downloaded ? (
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
ready
</Badge>
) : null}
</button>
);
})}
</div>
</div>
);
}
type SelectorTriggerProps = ComponentPropsWithoutRef<"button"> & {
value: string;
selectedModel: LocalModelInfo | null;
ggufVariant?: string | null;
inputId?: string;
disabled: boolean;
compact: boolean;
className?: string;
};
const SelectorTrigger = forwardRef<HTMLButtonElement, SelectorTriggerProps>(
function SelectorTrigger(
{
value,
selectedModel,
ggufVariant,
inputId,
disabled,
compact,
className,
...triggerProps
},
ref,
): ReactElement {
const selected = getSelectedModelSummary(value, selectedModel, ggufVariant);
return (
<button
{...triggerProps}
ref={ref}
id={inputId}
type="button"
disabled={disabled}
className={cn(
"nodrag flex w-full min-w-0 items-center gap-2 rounded-xl border border-border/70 bg-background px-3 text-left transition-colors hover:bg-muted/40 disabled:pointer-events-none disabled:opacity-60",
compact ? "min-h-8 py-1.5 text-xs" : "min-h-10 py-2 text-sm",
className,
)}
>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate font-medium",
!selected.label && "text-muted-foreground",
)}
>
{selected.label || "Choose a local model"}
</span>
{compact ? null : (
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="truncate">
{selected.label
? selected.source
: "Select from local and cached models"}
</span>
{selected.isGguf ? <span>GGUF</span> : null}
{ggufVariant ? (
<span className="truncate font-mono">{ggufVariant}</span>
) : null}
</span>
)}
</span>
{compact && ggufVariant ? (
<Badge
variant="secondary"
className="h-4 px-1.5 font-mono text-[10px]"
>
{ggufVariant}
</Badge>
) : null}
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</button>
);
},
);
function LocalModelRow({
model,
selected,
expanded,
probing,
ggufVariant,
onSelectModel,
onSelectVariant,
}: {
model: LocalModelInfo;
selected: boolean;
expanded: boolean;
probing: boolean;
ggufVariant?: string | null;
onSelectModel: (model: LocalModelInfo) => void;
onSelectVariant: (modelId: string, variant: string) => void;
}): ReactElement {
const expandable = isExpandableGguf(model);
const directGguf = isDirectGguf(model);
return (
<div>
<button
type="button"
disabled={probing}
onClick={() => onSelectModel(model)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-2.5 text-left text-sm transition-colors hover:bg-muted/50",
selected && "bg-muted/70 text-foreground ring-1 ring-border/70",
)}
>
{expandable ? (
expanded ? (
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
)
) : (
<span className="size-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{getModelLabel(model)}
</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{model.id}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{probing ? (
<Spinner className="size-3 text-muted-foreground" />
) : null}
{expandable || directGguf ? (
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
GGUF
</Badge>
) : null}
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
{sourceLabel(model)}
</Badge>
</span>
</button>
{expanded ? (
<LocalGgufVariantList
repoId={model.id}
selectedVariant={selected ? ggufVariant : null}
onSelect={(variant) => onSelectVariant(model.id, variant)}
/>
) : null}
</div>
);
}
function LocalModelResults({
loading,
error,
models,
value,
ggufVariant,
expandedModelId,
probingVariantModelId,
onRefresh,
onSelectModel,
onSelectVariant,
}: {
loading: boolean;
error: string | null;
models: LocalModelInfo[];
value: string;
ggufVariant?: string | null;
expandedModelId: string | null;
probingVariantModelId: string | null;
onRefresh: () => void;
onSelectModel: (model: LocalModelInfo) => void;
onSelectVariant: (modelId: string, variant: string) => void;
}): ReactElement {
if (loading) {
return (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
<Spinner className="size-3" />
Scanning local models...
</div>
);
}
if (error) {
return (
<div className="space-y-2 px-3 py-3 text-xs">
<p className="text-destructive">{error}</p>
<Button type="button" variant="outline" size="xs" onClick={onRefresh}>
Try again
</Button>
</div>
);
}
if (models.length === 0) {
return (
<div className="space-y-2 px-3 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">No local models found.</p>
<p>
Download a model or add a scan folder from Chat, then refresh this
list.
</p>
<Link
to="/chat"
className="inline-flex font-medium text-primary underline-offset-4 hover:underline"
>
Open Chat model picker
</Link>
</div>
);
}
return (
<div className="space-y-1">
{models.map((model) => (
<LocalModelRow
key={model.id}
model={model}
selected={model.id === value}
expanded={expandedModelId === model.id}
probing={probingVariantModelId === model.id}
ggufVariant={ggufVariant}
onSelectModel={onSelectModel}
onSelectVariant={onSelectVariant}
/>
))}
</div>
);
}
export function LocalRecipeModelSelector({
value,
ggufVariant,
onChange,
inputId,
disabled = false,
compact = false,
className,
}: LocalRecipeModelSelectorProps): ReactElement {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [models, setModels] = useState<LocalModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedModelId, setExpandedModelId] = useState<string | null>(null);
const [probingVariantModelId, setProbingVariantModelId] = useState<
string | null
>(null);
const [refreshKey, setRefreshKey] = useState(0);
const requestModelRefresh = useCallback(() => {
setLoading(true);
setError(null);
setRefreshKey((key) => key + 1);
}, []);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) {
requestModelRefresh();
}
},
[requestModelRefresh],
);
useEffect(() => {
if (!open || refreshKey < 0) {
return;
}
let cancelled = false;
listLocalModels()
.then((response) => {
if (cancelled) {
return;
}
setModels(response.models);
})
.catch((err) => {
if (cancelled) {
return;
}
setError(
err instanceof Error ? err.message : "Failed to list local models.",
);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, refreshKey]);
const selectedModel = useMemo(
() => models.find((model) => model.id === value) ?? null,
[models, value],
);
const filteredModels = useMemo(() => {
const needle = normalizeForSearch(query.trim());
if (!needle) {
return models;
}
return models.filter((model) => {
const haystack = normalizeForSearch(
`${model.id} ${model.display_name} ${model.model_id ?? ""} ${model.path}`,
);
return haystack.includes(needle);
});
}, [models, query]);
const selectModel = useCallback(
async (model: LocalModelInfo) => {
if (isExpandableGguf(model)) {
setExpandedModelId((current) =>
current === model.id ? null : model.id,
);
return;
}
if (!isDirectGguf(model)) {
setProbingVariantModelId(model.id);
try {
const response = await listGgufVariants(model.id);
if (response.variants.length > 0) {
setExpandedModelId(model.id);
return;
}
} catch {
// Non-GGUF local models commonly have no variant endpoint. Fall
// through to regular selection so users can still choose them.
} finally {
setProbingVariantModelId(null);
}
}
onChange(model.id, null);
setOpen(false);
},
[onChange],
);
const selectVariant = useCallback(
(modelId: string, variant: string) => {
onChange(modelId, variant);
setOpen(false);
},
[onChange],
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild={true}>
<SelectorTrigger
value={value}
selectedModel={selectedModel}
ggufVariant={ggufVariant}
inputId={inputId}
disabled={disabled}
compact={compact}
className={className}
/>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className="menu-soft-surface nodrag nowheel gap-0 overflow-hidden p-0"
style={{
width:
"min(max(var(--radix-popover-trigger-width), 34rem), calc(100vw - 1rem))",
}}
>
<div className="flex flex-col">
<div className="border-b border-border/60 p-2.5">
<div className="flex items-center gap-2">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter local models"
className="h-8 flex-1"
autoFocus={true}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={requestModelRefresh}
aria-label="Refresh local models"
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
</div>
<div
className="nowheel max-h-[min(24rem,calc(100vh-12rem))] overflow-y-auto overscroll-contain p-1.5"
onWheelCapture={(event) => event.stopPropagation()}
>
<LocalModelResults
loading={loading}
error={error}
models={filteredModels}
value={value}
ggufVariant={ggufVariant}
expandedModelId={expandedModelId}
probingVariantModelId={probingVariantModelId}
onRefresh={requestModelRefresh}
onSelectModel={selectModel}
onSelectVariant={selectVariant}
/>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -1,12 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Checkbox } from "@/components/ui/checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxContent,
@ -22,6 +22,7 @@ import type { ModelConfig } from "../../types";
import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
import { LocalRecipeModelSelector } from "./local-recipe-model-selector";
type ModelConfigDialogProps = {
config: ModelConfig;
@ -45,6 +46,7 @@ export function ModelConfigDialog({
const maxTokensId = `${config.id}-max-tokens`;
const timeoutId = `${config.id}-timeout`;
const extraBodyId = `${config.id}-inference-extra-body`;
const skipHealthCheckId = `${config.id}-skip-health-check`;
const providerAnchorRef = useRef<HTMLDivElement>(null);
const providerInputRef = useRef(config.provider);
// Sync providerInputRef with the current provider value. Updating a ref in
@ -61,16 +63,25 @@ export function ModelConfigDialog({
onUpdate({ [key]: value } as Partial<ModelConfig>);
};
// Apply provider selection while keeping the local-provider model autofill
// consistent across both dropdown selection and free-typed + blur input.
// Apply provider selection while clearing model identifiers that only make
// sense for the previous provider locality.
const applyProviderChange = (selectedProvider: string) => {
const isLocal = localProviderNames.has(selectedProvider);
if (isLocal && !config.model.trim()) {
onUpdate({ provider: selectedProvider, model: "local" });
const nextIsLocal = localProviderNames.has(selectedProvider);
if (isLinkedToLocal !== nextIsLocal) {
onUpdate({
provider: selectedProvider,
model: "",
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
});
return;
}
if (!isLocal && config.model === "local") {
onUpdate({ provider: selectedProvider, model: "" });
if (!nextIsLocal) {
onUpdate({
provider: selectedProvider,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
});
return;
}
updateField("provider", selectedProvider);
@ -88,8 +99,8 @@ export function ModelConfigDialog({
Set up one reusable model choice for your AI steps
</p>
<p className="mt-1 text-xs text-muted-foreground">
Choose the provider connection, enter the exact model ID, then save any
generation defaults you want to reuse.
Choose the provider connection, enter the exact model ID, then save
any generation defaults you want to reuse.
</p>
</div>
<div className="grid gap-1.5">
@ -144,15 +155,48 @@ export function ModelConfigDialog({
<FieldLabel
label="Model ID"
htmlFor={modelId}
hint={isLinkedToLocal ? "Uses the model loaded in Chat. Any value works here." : "The exact model name sent to the connection."}
/>
<Input
id={modelId}
className="nodrag"
placeholder={isLinkedToLocal ? "local" : "gpt-4o-mini"}
value={config.model}
onChange={(event) => updateField("model", event.target.value)}
hint={
isLinkedToLocal
? "Choose the local model Recipes should load before Run or Validate."
: "The exact model name sent to the connection."
}
/>
{isLinkedToLocal ? (
<LocalRecipeModelSelector
inputId={modelId}
value={
config.model.trim().toLowerCase() === "local" ? "" : config.model
}
ggufVariant={config.gguf_variant}
onChange={(model, variant) =>
onUpdate({
model,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: variant ?? undefined,
})
}
/>
) : (
<Input
id={modelId}
className="nodrag"
placeholder="gpt-4o-mini"
value={config.model}
onChange={(event) =>
onUpdate({
model: event.target.value,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
})
}
/>
)}
{isLinkedToLocal ? (
<p className="text-xs text-muted-foreground">
Recipes will load this model automatically. GGUF quantization is
saved with the preset.
</p>
) : null}
</div>
<div className="grid gap-3">
<div className="space-y-1">
@ -250,8 +294,12 @@ export function ModelConfigDialog({
}
/>
</div>
<label className="flex items-center gap-2 text-xs font-semibold uppercase text-muted-foreground">
<label
htmlFor={skipHealthCheckId}
className="flex items-center gap-2 text-xs font-semibold uppercase text-muted-foreground"
>
<Checkbox
id={skipHealthCheckId}
checked={config.skip_health_check ?? false}
onCheckedChange={(value) =>
updateField("skip_health_check", Boolean(value))

View file

@ -1,13 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { GithubIcon, PlayCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FieldLabel } from "../dialogs/shared/field-label";
import { LocalRecipeModelSelector } from "../dialogs/models/local-recipe-model-selector";
import { GithubRepoSeedForm } from "../dialogs/seed/seed-dialog";
import { FieldLabel } from "../dialogs/shared/field-label";
import type { ModelConfig, NodeConfig, SeedConfig } from "../types";
type GithubCrawlerEasyViewProps = {
@ -44,6 +45,21 @@ export function GithubCrawlerEasyView({
) ?? null,
[configs],
);
const localProviderNames = useMemo(() => {
const names = new Set<string>();
for (const config of Object.values(configs)) {
if (config.kind === "model_provider" && config.is_local === true) {
names.add(config.name);
}
}
return names;
}, [configs]);
const isModelLinkedToLocal = modelConfig
? localProviderNames.has(modelConfig.provider)
: false;
const modelValue = modelConfig?.model ?? "";
const localModelValue =
modelValue.trim().toLowerCase() === "local" ? "" : modelValue;
// Local buffer for the Rows input so the user can hold transient invalid
// state (empty while backspacing, partial digits, etc.) without the parent
@ -52,17 +68,40 @@ export function GithubCrawlerEasyView({
// blur we clamp back to a sane default if the user left it empty.
const [rowsText, setRowsText] = useState(String(rows));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- keep the draft input in sync when the parent resets rows.
setRowsText(String(rows));
}, [rows]);
const handleSeedUpdate = (patch: Partial<SeedConfig>): void => {
if (!seedConfig) return;
if (!seedConfig) {
return;
}
updateConfig(seedConfig.id, patch);
};
const handleModelChange = (value: string): void => {
if (!modelConfig) return;
updateConfig(modelConfig.id, { model: value });
if (!modelConfig) {
return;
}
updateConfig(modelConfig.id, {
model: value,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
});
};
const handleLocalModelChange = (
model: string,
variant?: string | null,
): void => {
if (!modelConfig) {
return;
}
updateConfig(modelConfig.id, {
model,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: variant ?? undefined,
});
};
if (!seedConfig) {
@ -96,9 +135,8 @@ export function GithubCrawlerEasyView({
<h2 className="text-base font-semibold">GitHub Crawler</h2>
<p className="text-xs text-muted-foreground">
Crawl real GitHub issues and PRs and turn each thread into a{" "}
<code>{"{User, Assistant}"}</code> training pair.
Defaults use the server's <code>GH_TOKEN</code> env var and the
bundled local model.
<code>{"{User, Assistant}"}</code> training pair. Defaults use the
server's <code>GH_TOKEN</code> env var and the bundled local model.
</p>
</div>
</div>
@ -149,15 +187,28 @@ export function GithubCrawlerEasyView({
<div className="grid gap-1.5">
<FieldLabel
label="Model"
hint="OpenAI-compatible model id. Local GGUFs run on the bundled llama-server."
/>
<Input
className="nodrag font-mono text-xs"
value={modelConfig?.model ?? ""}
onChange={(event) => handleModelChange(event.target.value)}
placeholder="unsloth/gemma-4-E2B-it-GGUF"
disabled={!modelConfig}
hint={
isModelLinkedToLocal
? "Choose the local model this recipe should load."
: "OpenAI-compatible model id."
}
/>
{isModelLinkedToLocal ? (
<LocalRecipeModelSelector
value={localModelValue}
ggufVariant={modelConfig?.gguf_variant}
onChange={handleLocalModelChange}
disabled={!modelConfig}
/>
) : (
<Input
className="nodrag font-mono text-xs"
value={modelValue}
onChange={(event) => handleModelChange(event.target.value)}
placeholder="unsloth/gemma-4-E2B-it-GGUF"
disabled={!modelConfig}
/>
)}
</div>
</div>
</section>

View file

@ -40,6 +40,11 @@ type TrackRecipeExecutionParams = {
onPreviewSuccess?: () => void;
};
export type TrackRecipeExecutionResult = {
success: boolean;
terminal: boolean;
};
function isTerminalStatus(status: RecipeExecutionStatus): boolean {
return status === "completed" || status === "error" || status === "cancelled";
}
@ -53,7 +58,8 @@ function normalizeCompletedProgress(input: {
} {
const { latestExecution, rows } = input;
const progressTotal =
typeof latestExecution.progress?.total === "number" && latestExecution.progress.total > 0
typeof latestExecution.progress?.total === "number" &&
latestExecution.progress.total > 0
? latestExecution.progress.total
: latestExecution.rows > 0
? latestExecution.rows
@ -92,7 +98,7 @@ export async function trackRecipeExecution({
onUpsert,
onSetPreviewErrors,
onPreviewSuccess,
}: TrackRecipeExecutionParams): Promise<boolean> {
}: TrackRecipeExecutionParams): Promise<TrackRecipeExecutionResult> {
let done = false;
let lastStatus: RecipeExecutionStatus = initialExecution.status;
let completedEventPayload: Record<string, unknown> | null = null;
@ -124,7 +130,9 @@ export async function trackRecipeExecution({
}
const eventType =
typeof event.payload.type === "string" ? event.payload.type : event.event;
typeof event.payload.type === "string"
? event.payload.type
: event.event;
if (eventType === "job.started") {
latestExecution = {
@ -163,7 +171,7 @@ export async function trackRecipeExecution({
error:
typeof event.payload.error === "string"
? event.payload.error
: latestExecution.error ?? `${label} failed.`,
: (latestExecution.error ?? `${label} failed.`),
};
onUpsert(latestExecution);
return;
@ -178,6 +186,19 @@ export async function trackRecipeExecution({
return;
}
if (eventType === "job.cancelled") {
lastStatus = "cancelled";
done = true;
latestExecution = {
...latestExecution,
status: "cancelled",
finishedAt: Date.now(),
error: latestExecution.error ?? "Run cancelled.",
};
onUpsert(latestExecution);
return;
}
if (changed) {
onUpsert(latestExecution);
}
@ -189,6 +210,9 @@ export async function trackRecipeExecution({
try {
while (!done) {
const status = await getRecipeJobStatus(jobId);
if (done && isTerminalStatus(lastStatus)) {
break;
}
const mappedStatus = mapJobStatus(status.status);
lastStatus = mappedStatus;
latestExecution = applyExecutionStatusSnapshot(latestExecution, status);
@ -200,18 +224,19 @@ export async function trackRecipeExecution({
}
}
} catch (error) {
const message = toErrorMessage(error, `${label} failed.`);
latestExecution = {
...latestExecution,
status: "error",
error: message,
finishedAt: Date.now(),
};
onUpsert(latestExecution);
if (notify) {
toastError(`${label} failed`, message);
const terminal = isTerminalStatus(lastStatus);
if (!terminal) {
const message = toErrorMessage(error, `${label} failed.`);
latestExecution = {
...latestExecution,
error: message,
};
onUpsert(latestExecution);
if (notify) {
toastError(`${label} failed`, message);
}
return { success: false, terminal: false };
}
return false;
} finally {
eventsAbortController.abort();
}
@ -220,7 +245,10 @@ export async function trackRecipeExecution({
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const finalStatus = await getRecipeJobStatus(jobId);
latestExecution = applyExecutionStatusSnapshot(latestExecution, finalStatus);
latestExecution = applyExecutionStatusSnapshot(
latestExecution,
finalStatus,
);
} catch {
break;
}
@ -229,19 +257,20 @@ export async function trackRecipeExecution({
}
}
const eventAnalysis = completedEventPayload
? completedEventPayload["analysis"]
: null;
const eventDataset = completedEventPayload
? completedEventPayload["dataset"]
: null;
const completedPayload = completedEventPayload as Record<
string,
unknown
> | null;
const eventAnalysis = completedPayload ? completedPayload.analysis : null;
const eventDataset = completedPayload ? completedPayload.dataset : null;
const eventProcessorArtifacts =
completedEventPayload &&
typeof completedEventPayload["processor_artifacts"] === "object" &&
completedEventPayload["processor_artifacts"] !== null
? (completedEventPayload["processor_artifacts"] as Record<string, unknown>)
completedPayload &&
typeof completedPayload.processor_artifacts === "object" &&
completedPayload.processor_artifacts !== null
? (completedPayload.processor_artifacts as Record<string, unknown>)
: null;
const shouldFetchPreviewDataset = kind === "preview" && !Array.isArray(eventDataset);
const shouldFetchPreviewDataset =
kind === "preview" && !Array.isArray(eventDataset);
const shouldFetchAnalysis =
!completedEventPayload ||
typeof eventAnalysis !== "object" ||
@ -262,9 +291,7 @@ export async function trackRecipeExecution({
? normalizeAnalysis(analysisResult.value)
: latestExecution.analysis;
const datasetResponse =
datasetResult.status === "fulfilled"
? datasetResult.value
: null;
datasetResult.status === "fulfilled" ? datasetResult.value : null;
const dataset = datasetResponse
? normalizeDatasetRows(datasetResponse.dataset)
: latestExecution.dataset;
@ -272,7 +299,10 @@ export async function trackRecipeExecution({
datasetResponse && typeof datasetResponse.total === "number"
? datasetResponse.total
: latestExecution.datasetTotal;
const completedProgress = normalizeCompletedProgress({ latestExecution, rows });
const completedProgress = normalizeCompletedProgress({
latestExecution,
rows,
});
latestExecution = {
...latestExecution,
@ -285,7 +315,8 @@ export async function trackRecipeExecution({
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
error: null,
processor_artifacts: eventProcessorArtifacts ?? latestExecution.processor_artifacts,
processor_artifacts:
eventProcessorArtifacts ?? latestExecution.processor_artifacts,
finishedAt: latestExecution.finishedAt ?? Date.now(),
};
onUpsert(latestExecution);
@ -299,7 +330,7 @@ export async function trackRecipeExecution({
toastSuccess("Full run completed.");
}
}
return true;
return { success: true, terminal: true };
}
if (lastStatus === "cancelled") {
@ -313,7 +344,7 @@ export async function trackRecipeExecution({
if (notify) {
toastError(`${label} cancelled`, "The execution was cancelled.");
}
return false;
return { success: false, terminal: true };
}
latestExecution = {
@ -326,5 +357,5 @@ export async function trackRecipeExecution({
if (notify) {
toastError(`${label} failed`, latestExecution.error ?? "Execution failed.");
}
return false;
return { success: false, terminal: true };
}

View file

@ -1,14 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { getInferenceStatus, loadModel } from "@/features/chat";
import { toast } from "@/lib/toast";
import { toastError } from "@/shared/toast";
import {
getInferenceStatus,
loadModel,
} from "@/features/chat/api/chat-api";
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import {
cancelRecipeJob,
createRecipeJob,
@ -23,8 +20,8 @@ import type {
import {
DATASET_PAGE_SIZE,
executionLabel,
normalizeRunName,
normalizeDatasetRows,
normalizeRunName,
toErrorMessage,
withExecutionDefaults,
} from "../executions/execution-helpers";
@ -32,84 +29,243 @@ import {
findResumableExecution,
loadSortedRecipeExecutions,
} from "../executions/hydration";
import { createBaseExecutionRecord } from "../executions/runtime";
import {
buildExecutionPayload,
sanitizeExecutionRows,
} from "../executions/run-settings";
import { createBaseExecutionRecord } from "../executions/runtime";
import { trackRecipeExecution } from "../executions/tracker";
import {
type RecipeRunSettings,
useRecipeExecutionsStore,
} from "../stores/recipe-executions";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
import type {
RecipePayload,
RecipePayloadResult,
} from "../utils/payload/types";
/**
* Auto-load the local model before running a recipe that uses it.
*
* Looks at payload.recipe.model_providers for any provider with is_local=true,
* finds the bound model_configs and asks the backend to load whichever model
* the first local-bound model_config points at. Skips when the inference
* server already has that exact model active. This removes the "open /chat
* first" prerequisite that users kept tripping on.
*/
async function ensureLocalModelLoaded(
payload: RecipePayload,
): Promise<string | null> {
const GGUF_MODEL_PATTERN = /gguf/i;
function collectUsedLlmModelAliases(payload: RecipePayload): Set<string> {
const columns = Array.isArray(payload.recipe.columns)
? payload.recipe.columns
: [];
const aliases = new Set<string>();
for (const column of columns) {
const columnType = column.column_type;
if (typeof columnType !== "string" || !columnType.startsWith("llm-")) {
continue;
}
const alias = column.model_alias;
if (typeof alias === "string" && alias.trim()) {
aliases.add(alias.trim());
}
}
return aliases;
}
type LocalModelSelection = {
target: string;
ggufVariant: string;
aliases: string[];
};
type LocalModelLoadPlan =
| { selection: LocalModelSelection; error: null; legacyAliases?: never }
| { selection: null; error: string; legacyAliases?: never }
| { selection: null; error: null; legacyAliases: string[] };
type RestorableLocalModelSnapshot = {
selection: LocalModelSelection | null;
unrestorableLabel: string | null;
};
function getLocalProviderNames(payload: RecipePayload): Set<string> {
const providers = Array.isArray(payload.recipe.model_providers)
? (payload.recipe.model_providers as Array<Record<string, unknown>>)
? (payload.recipe.model_providers as Record<string, unknown>[])
: [];
const localProviderNames = new Set<string>();
for (const p of providers) {
if (p.is_local === true && typeof p.name === "string") {
localProviderNames.add(p.name);
for (const provider of providers) {
if (provider.is_local === true && typeof provider.name === "string") {
localProviderNames.add(provider.name);
}
}
if (localProviderNames.size === 0) {
return null;
return localProviderNames;
}
function findUsedLocalModelConfigs(
payload: RecipePayload,
localProviderNames: Set<string>,
): Record<string, unknown>[] {
const usedAliases = collectUsedLlmModelAliases(payload);
if (usedAliases.size === 0) {
return [];
}
const modelConfigs = Array.isArray(payload.recipe.model_configs)
? (payload.recipe.model_configs as Array<Record<string, unknown>>)
? payload.recipe.model_configs
: [];
const boundConfig = modelConfigs.find(
(c) => typeof c.provider === "string" && localProviderNames.has(c.provider),
);
return modelConfigs.filter((config) => {
const provider = config.provider;
const alias = config.alias;
return (
typeof provider === "string" &&
localProviderNames.has(provider) &&
typeof alias === "string" &&
usedAliases.has(alias)
);
});
}
function readLocalModelSelection(
boundConfig: Record<string, unknown>,
): LocalModelLoadPlan {
const alias =
typeof boundConfig.alias === "string" ? boundConfig.alias : "local model";
const target =
typeof boundConfig?.model === "string" ? boundConfig.model.trim() : "";
typeof boundConfig.model === "string" ? boundConfig.model.trim() : "";
const ggufVariant =
typeof boundConfig.gguf_variant === "string"
? boundConfig.gguf_variant.trim()
: "";
if (!target) {
return null;
return {
selection: null,
error: `Model config ${alias}: choose a local model before validating or running this recipe.`,
};
}
if (target.toLowerCase() === "local") {
return { selection: null, error: null, legacyAliases: [alias] };
}
return { selection: { target, ggufVariant, aliases: [alias] }, error: null };
}
function getLocalModelLoadPlan(
boundConfigs: Record<string, unknown>[],
): LocalModelLoadPlan | null {
const selections = new Map<string, LocalModelSelection>();
const legacyAliases: string[] = [];
for (const boundConfig of boundConfigs) {
const next = readLocalModelSelection(boundConfig);
if (next.error) {
return next;
}
if (next.legacyAliases) {
legacyAliases.push(...next.legacyAliases);
continue;
}
const selection = next.selection;
if (!selection) {
continue;
}
const key = `${selection.target.toLowerCase()}\u0000${selection.ggufVariant}`;
const existing = selections.get(key);
if (existing) {
existing.aliases.push(...selection.aliases);
continue;
}
selections.set(key, selection);
}
if (legacyAliases.length > 0 && selections.size > 0) {
const aliases = [
...legacyAliases,
...[...selections.values()].flatMap((selection) => selection.aliases),
].join(", ");
return {
selection: null,
error: `Recipes found mixed legacy and selected local models. Reselect the same concrete local model for: ${aliases}.`,
};
}
if (legacyAliases.length > 0) {
return { selection: null, error: null, legacyAliases };
}
if (selections.size > 1) {
const aliases = [...selections.values()]
.flatMap((selection) => selection.aliases)
.join(", ");
return {
selection: null,
error: `Recipes supports one active local model per run. Select the same local model and GGUF variant for: ${aliases}.`,
};
}
const selection = [...selections.values()][0];
return selection ? { selection, error: null } : null;
}
function isDirectGgufTarget(target: string): boolean {
return target.toLowerCase().endsWith(".gguf");
}
function localSelectionMatchesActive(input: {
target: string;
ggufVariant: string;
activeModel: string | null | undefined;
activeVariant: string;
}): boolean {
const { target, ggufVariant, activeModel, activeVariant } = input;
if (!activeModel || activeModel.toLowerCase() !== target.toLowerCase()) {
return false;
}
return (
activeVariant === ggufVariant ||
(isDirectGgufTarget(target) && !ggufVariant)
);
}
async function isLocalModelAlreadyLoaded(
selection: LocalModelSelection,
): Promise<boolean> {
const { target, ggufVariant } = selection;
try {
const status = await getInferenceStatus();
if (
status.active_model &&
status.active_model.toLowerCase() === target.toLowerCase()
) {
return null;
}
return localSelectionMatchesActive({
target,
ggufVariant,
activeModel: status.model_identifier ?? status.active_model,
activeVariant: status.gguf_variant?.trim() ?? "",
});
} catch {
// Fall through to load attempt; the backend will re-error if needed.
return false;
}
}
const toastId = toast.loading(`Loading ${target}`, {
async function loadLocalModelSelection(
selection: LocalModelSelection,
): Promise<string | null> {
const { target, ggufVariant } = selection;
const modelLabel = ggufVariant ? `${target} (${ggufVariant})` : target;
const toastId = toast.loading(`Loading ${modelLabel}...`, {
description: "Starting the local inference server for this recipe.",
});
try {
const isGguf = /gguf/i.test(target);
const isGguf = GGUF_MODEL_PATTERN.test(target) || Boolean(ggufVariant);
await loadModel({
// biome-ignore lint/style/useNamingConvention: api schema
model_path: target,
// biome-ignore lint/style/useNamingConvention: api schema
hf_token: null,
// biome-ignore lint/style/useNamingConvention: api schema
max_seq_length: isGguf ? 0 : 4096,
// biome-ignore lint/style/useNamingConvention: api schema
load_in_4bit: true,
// biome-ignore lint/style/useNamingConvention: api schema
is_lora: false,
gguf_variant: null,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: ggufVariant || null,
// biome-ignore lint/style/useNamingConvention: api schema
trust_remote_code: false,
// biome-ignore lint/style/useNamingConvention: api schema
chat_template_override: null,
// biome-ignore lint/style/useNamingConvention: api schema
cache_type_kv: null,
// biome-ignore lint/style/useNamingConvention: api schema
speculative_type: null,
});
toast.success(`Loaded ${target}`, { id: toastId, duration: 2000 });
toast.success(`Loaded ${modelLabel}`, { id: toastId, duration: 2000 });
return null;
} catch (error) {
toast.dismiss(toastId);
@ -117,6 +273,147 @@ async function ensureLocalModelLoaded(
}
}
function getLocalModelLoadPlanForPayload(
payload: RecipePayload,
): LocalModelLoadPlan | null {
const localProviderNames = getLocalProviderNames(payload);
if (localProviderNames.size === 0) {
return null;
}
const boundConfigs = findUsedLocalModelConfigs(payload, localProviderNames);
return getLocalModelLoadPlan(boundConfigs);
}
async function getActiveLocalModelSelection(): Promise<LocalModelSelection | null> {
try {
const status = await getInferenceStatus();
const target = status.active_model?.trim();
if (!target) {
return null;
}
return {
target,
ggufVariant: status.gguf_variant?.trim() ?? "",
aliases: ["previous Chat model"],
};
} catch {
return null;
}
}
async function getRestorableActiveLocalModelSelection(): Promise<RestorableLocalModelSnapshot> {
try {
const status = await getInferenceStatus();
const activeLabel = status.active_model?.trim() ?? null;
const target = (
status.model_identifier ?? (status.is_gguf ? null : status.active_model)
)?.trim();
if (!target) {
return {
selection: null,
unrestorableLabel: activeLabel,
};
}
return {
selection: {
target,
ggufVariant: status.gguf_variant?.trim() ?? "",
aliases: ["previous Chat model"],
},
unrestorableLabel: null,
};
} catch {
return { selection: null, unrestorableLabel: null };
}
}
function isSameLocalModelSelection(
left: LocalModelSelection | null,
right: LocalModelSelection,
): boolean {
return Boolean(
left &&
left.target.toLowerCase() === right.target.toLowerCase() &&
left.ggufVariant === right.ggufVariant,
);
}
async function ensureLocalModelLoaded(
payload: RecipePayload,
): Promise<string | null> {
const loadPlan = getLocalModelLoadPlanForPayload(payload);
if (!loadPlan) {
return null;
}
if (loadPlan.legacyAliases) {
const activeSelection = await getActiveLocalModelSelection();
return activeSelection
? null
: `Existing recipe uses legacy local model for ${loadPlan.legacyAliases.join(", ")}. Select a concrete local model or load one in Chat.`;
}
if (!loadPlan.selection) {
return loadPlan.error;
}
if (await isLocalModelAlreadyLoaded(loadPlan.selection)) {
return null;
}
return loadLocalModelSelection(loadPlan.selection);
}
async function prepareLocalModelForRun(payload: RecipePayload): Promise<{
error: string | null;
restorePrevious: (() => Promise<void>) | null;
}> {
const loadPlan = getLocalModelLoadPlanForPayload(payload);
if (!loadPlan) {
return { error: null, restorePrevious: null };
}
if (loadPlan.legacyAliases) {
const activeSelection = await getActiveLocalModelSelection();
return activeSelection
? { error: null, restorePrevious: null }
: {
error: `Existing recipe uses legacy local model for ${loadPlan.legacyAliases.join(", ")}. Select a concrete local model or load one in Chat.`,
restorePrevious: null,
};
}
if (!loadPlan.selection) {
return { error: loadPlan.error, restorePrevious: null };
}
if (await isLocalModelAlreadyLoaded(loadPlan.selection)) {
return { error: null, restorePrevious: null };
}
const previousSnapshot = await getRestorableActiveLocalModelSelection();
const previousSelection = previousSnapshot.selection;
const error = await loadLocalModelSelection(loadPlan.selection);
if (error) {
return { error, restorePrevious: null };
}
if (isSameLocalModelSelection(previousSelection, loadPlan.selection)) {
return { error: null, restorePrevious: null };
}
return {
error: null,
restorePrevious: previousSelection
? async () => {
const restoreError = await loadLocalModelSelection(previousSelection);
if (restoreError) {
toastError("Could not restore previous local model", restoreError);
}
}
: previousSnapshot.unrestorableLabel
? () => {
toast.warning("Previous local model was not restored", {
description: `${previousSnapshot.unrestorableLabel} was selected from a native file path. Reopen it in Chat to continue with that model.`,
});
return Promise.resolve();
}
: null,
};
}
type UseRecipeExecutionsParams = {
recipeId: string;
currentSignature: string;
@ -161,7 +458,11 @@ type UseRecipeExecutionsResult = {
};
function formatValidationMessages(input: {
errors: Array<{ message: string; path?: string | null; code?: string | null }>;
errors: Array<{
message: string;
path?: string | null;
code?: string | null;
}>;
}): string[] {
return input.errors.map((item) => {
const path = item.path?.trim();
@ -249,7 +550,8 @@ export function useRecipeExecutions({
(record: RecipeExecutionRecord): void => {
const normalizedRecord = withExecutionDefaults(record);
upsertExecution(normalizedRecord);
void saveRecipeExecution(normalizedRecord).catch((error) => {
saveRecipeExecution(normalizedRecord).catch((error) => {
// biome-ignore lint/suspicious/noConsole: background persistence failures should not interrupt the UI
console.error("Save recipe execution failed:", error);
});
},
@ -287,7 +589,7 @@ export function useRecipeExecutions({
return;
}
void trackRecipeExecution({
trackRecipeExecution({
label: executionLabel(resumable.kind),
kind: resumable.kind,
rows: resumable.rows,
@ -299,11 +601,12 @@ export function useRecipeExecutions({
onPreviewSuccess,
});
} catch (error) {
// biome-ignore lint/suspicious/noConsole: hydration failures are non-blocking diagnostics
console.error("Load recipe executions failed:", error);
}
}
void hydrate();
hydrate();
return () => {
cancelled = true;
@ -344,9 +647,11 @@ export function useRecipeExecutions({
rows: number;
settings: RecipeRunSettings;
runName: string | null;
restorePrevious?: (() => Promise<void>) | null;
}): Promise<boolean> => {
const { kind, payload, rows, settings, runName } = input;
const setLoading = kind === "preview" ? setPreviewLoading : setFullLoading;
const { kind, payload, rows, settings, runName, restorePrevious } = input;
const setLoading =
kind === "preview" ? setPreviewLoading : setFullLoading;
const label = executionLabel(kind);
setLoading(true);
@ -362,6 +667,8 @@ export function useRecipeExecutions({
onExecutionStart?.();
setRunDialogOpen(false);
let jobCreated = false;
let shouldRestorePrevious = false;
try {
const jobPayload = buildExecutionPayload({
payload,
@ -371,13 +678,14 @@ export function useRecipeExecutions({
runName,
});
const createdJob = await createRecipeJob(jobPayload);
jobCreated = true;
const executionWithJob = {
...baseExecution,
jobId: createdJob.job_id,
};
upsertAndPersist(executionWithJob);
return await trackRecipeExecution({
const tracked = await trackRecipeExecution({
label,
kind,
rows,
@ -388,6 +696,8 @@ export function useRecipeExecutions({
onSetPreviewErrors: setRunErrors,
onPreviewSuccess,
});
shouldRestorePrevious = tracked.terminal;
return tracked.success;
} catch (error) {
const message = toErrorMessage(error, `${label} request failed.`);
upsertAndPersist({
@ -398,8 +708,14 @@ export function useRecipeExecutions({
});
setRunErrors([message]);
toastError(`${label} failed`, message);
if (!jobCreated) {
shouldRestorePrevious = true;
}
return false;
} finally {
if (shouldRestorePrevious && restorePrevious) {
await restorePrevious();
}
setLoading(false);
}
},
@ -416,6 +732,48 @@ export function useRecipeExecutions({
],
);
const prepareLocalModelForExecution = useCallback(
async (
payload: RecipePayload,
): Promise<(() => Promise<void>) | null | false> => {
const { error, restorePrevious } = await prepareLocalModelForRun(payload);
if (!error) {
return restorePrevious;
}
setRunErrors([error]);
toastError("Local model failed to load", error);
return false;
},
[setRunErrors],
);
const validateExecutionPayload = useCallback(
async (
executionPayload: Parameters<typeof validateRecipe>[0],
): Promise<boolean> => {
try {
const validation = await validateRecipe(executionPayload);
if (validation.valid) {
return true;
}
const errors = formatValidationMessages({
errors: validation.errors,
});
const fallback = validation.raw_detail ?? "Validation failed.";
const nextErrors = errors.length > 0 ? errors : [fallback];
setRunErrors(nextErrors);
toastError("Validation failed", nextErrors[0]);
return false;
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setRunErrors([message]);
toastError("Validation failed", message);
return false;
}
},
[setRunErrors],
);
const runWithValidation = useCallback(
async (
kind: RecipeExecutionKind,
@ -435,20 +793,11 @@ export function useRecipeExecutions({
return false;
}
// Flip to the Runs pane BEFORE we run ensureLocalModelLoaded + validate.
// Validation re-crawls the seed (multiple seconds for the github_repo
// reader) and the user otherwise stares at a "Running..." button with
// nothing else changing. runExecution() later no-ops this callback if
// the view has already been flipped, so we fire it once here.
// Flip to the Runs pane before validation starts. Validation can re-crawl
// the seed (multiple seconds for the github_repo reader), and runExecution()
// later no-ops this callback if the view has already been flipped.
onExecutionStart?.();
const localLoadError = await ensureLocalModelLoaded(payload);
if (localLoadError) {
setRunErrors([localLoadError]);
toastError("Local model failed to load", localLoadError);
return false;
}
const normalizedRows = sanitizeExecutionRows(rows, kind);
const executionPayload = buildExecutionPayload({
payload,
@ -458,20 +807,17 @@ export function useRecipeExecutions({
runName,
});
try {
const validation = await validateRecipe(executionPayload);
if (!validation.valid) {
const errors = formatValidationMessages({ errors: validation.errors });
const fallback = validation.raw_detail ?? "Validation failed.";
const nextErrors = errors.length > 0 ? errors : [fallback];
setRunErrors(nextErrors);
toastError("Validation failed", nextErrors[0]);
return false;
}
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setRunErrors([message]);
toastError("Validation failed", message);
if (!(await validateExecutionPayload(executionPayload))) {
return false;
}
// Recipe and Chat share one singleton local inference backend. This
// direct load is a point-in-time handoff to job creation, not a lease:
// if Chat swaps models after this succeeds, the backend will reject or
// run against the active backend state. A future generation token should
// be validated across this load and the `/jobs` loaded-model gate.
const restorePrevious = await prepareLocalModelForExecution(payload);
if (restorePrevious === false) {
return false;
}
@ -481,26 +827,29 @@ export function useRecipeExecutions({
rows: normalizedRows,
settings: runSettings,
runName,
restorePrevious,
});
},
[
onExecutionStart,
prepareLocalModelForExecution,
readExecutablePayload,
runExecution,
runSettings,
setRunErrors,
validateExecutionPayload,
],
);
const runPreview = useCallback(async (): Promise<boolean> => {
const runPreview = useCallback((): Promise<boolean> => {
return runWithValidation("preview", previewRows, null);
}, [previewRows, runWithValidation]);
const runFull = useCallback(async (): Promise<boolean> => {
const runFull = useCallback((): Promise<boolean> => {
return runWithValidation("full", fullRows, fullRunName);
}, [fullRows, fullRunName, runWithValidation]);
const runFromDialog = useCallback(async (): Promise<boolean> => {
const runFromDialog = useCallback((): Promise<boolean> => {
setValidateResult(null);
if (runDialogKind === "preview") {
return runPreview();
@ -512,9 +861,10 @@ export function useRecipeExecutions({
setRunErrors([]);
const payload = readPayload();
if (!payload) {
const nextErrors = payloadResult.errors.length > 0
? payloadResult.errors
: [payloadErrorMessage];
const nextErrors =
payloadResult.errors.length > 0
? payloadResult.errors
: [payloadErrorMessage];
setValidateResult({
valid: false,
errors: nextErrors,
@ -525,24 +875,46 @@ export function useRecipeExecutions({
const rows = runDialogKind === "preview" ? previewRows : fullRows;
const normalizedRows = sanitizeExecutionRows(rows, runDialogKind);
const executionPayload = buildExecutionPayload({
payload,
kind: runDialogKind,
rows: normalizedRows,
settings: runSettings,
runName: runDialogKind === "full" ? normalizeRunName(fullRunName) : null,
});
setValidateLoading(true);
try {
const executionPayload = buildExecutionPayload({
payload,
kind: runDialogKind,
rows: normalizedRows,
settings: runSettings,
runName:
runDialogKind === "full" ? normalizeRunName(fullRunName) : null,
});
const validation = await validateRecipe(executionPayload);
const errors = formatValidationMessages({ errors: validation.errors });
if (!validation.valid) {
setValidateResult({
valid: false,
errors,
rawDetail: validation.raw_detail ?? null,
});
return false;
}
const localLoadError = await ensureLocalModelLoaded(payload);
if (localLoadError) {
setRunErrors([localLoadError]);
setValidateResult({
valid: false,
errors: [localLoadError],
rawDetail: null,
});
toastError("Local model failed to load", localLoadError);
return false;
}
setValidateResult({
valid: validation.valid,
valid: true,
errors,
rawDetail: validation.raw_detail ?? null,
});
return validation.valid;
return true;
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setValidateResult({
@ -612,7 +984,12 @@ export function useRecipeExecutions({
const loadExecutionDatasetPage = useCallback(
async (id: string, page: number): Promise<void> => {
const execution = executions.find((entry) => entry.id === id);
if (!execution || execution.kind !== "full" || !execution.jobId || page < 1) {
if (
!execution ||
execution.kind !== "full" ||
!execution.jobId ||
page < 1
) {
return;
}
@ -625,7 +1002,9 @@ export function useRecipeExecutions({
});
const dataset = normalizeDatasetRows(response.dataset);
const total =
typeof response.total === "number" ? response.total : execution.datasetTotal;
typeof response.total === "number"
? response.total
: execution.datasetTotal;
upsertAndPersist({
...execution,
dataset,

View file

@ -97,7 +97,9 @@ export function applyRenameToConfig(
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
target_columns: targets.map((target) => (target === from ? to : target)),
target_columns: targets.map((target) =>
target === from ? to : target,
),
};
}
}
@ -137,14 +139,12 @@ export function applyRemovalToConfig(
}
if (config.kind === "model_config" && config.provider === ref) {
const base = next as ModelConfig;
// Clear the synthetic "local" placeholder when the provider that was
// a local provider is removed; otherwise the stale placeholder would
// pass validation against a future external provider and then fail
// at runtime against a real API ("model not found").
next = {
...base,
provider: "",
model: base.model === "local" ? "" : base.model,
model: "",
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
};
}
if (config.kind === "llm" && config.model_alias === ref) {
@ -156,7 +156,9 @@ export function applyRemovalToConfig(
next = { ...base, tool_alias: "" };
}
if (config.kind === "validator") {
const targets = (config.target_columns ?? []).filter((target) => target !== ref);
const targets = (config.target_columns ?? []).filter(
(target) => target !== ref,
);
if (targets.length !== (config.target_columns ?? []).length) {
const base = next as typeof config;
next = {
@ -206,5 +208,7 @@ export function applyRemovalToConfigs(
if (!ref) {
return configs;
}
return applyConfigTransform(configs, (config) => applyRemovalToConfig(config, ref));
return applyConfigTransform(configs, (config) =>
applyRemovalToConfig(config, ref),
);
}

View file

@ -12,23 +12,23 @@ import {
applyNodeChanges,
} from "@xyflow/react";
import { create } from "zustand";
import type {
RecipeNode,
RecipeProcessorConfig,
LayoutDirection,
LlmType,
NodeConfig,
SeedSourceType,
SamplerType,
} from "../types";
import {
getBlockDefinition,
type BlockKind,
type BlockType,
type SeedBlockType,
getBlockDefinition,
} from "../blocks/registry";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import type {
LayoutDirection,
LlmType,
NodeConfig,
RecipeNode,
RecipeProcessorConfig,
SamplerType,
SeedSourceType,
} from "../types";
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import {
HANDLE_IDS,
normalizeRecipeHandleId,
@ -42,8 +42,8 @@ import {
} from "./helpers/model-infra-layout";
import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals";
import {
applyRenameToConfigs,
applyLayoutDirectionToNodes,
applyRenameToConfigs,
buildNodeUpdate,
syncEdgesForConfigPatch,
syncSubcategoryConfigsForCategoryUpdate,
@ -97,7 +97,11 @@ type RecipeStudioState = {
position?: XYPosition,
openDialog?: boolean,
) => void;
addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
addLlmNode: (
type: LlmType,
position?: XYPosition,
openDialog?: boolean,
) => void;
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
addToolProfileNode: (position?: XYPosition, openDialog?: boolean) => void;
@ -250,7 +254,10 @@ function connectSemantic(
};
}
function isModelSemanticEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
function isModelSemanticEdge(
edge: Edge,
configs: Record<string, NodeConfig>,
): boolean {
const source = configs[edge.source];
const target = configs[edge.target];
return Boolean(
@ -315,12 +322,16 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
auxNodePositions: {},
llmAuxVisibility: state.llmAuxVisibility,
});
const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, {
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
configs: state.configs,
});
const { nodes } = getLayoutedElements(
displayGraph.nodes,
displayGraph.edges,
{
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
configs: state.configs,
},
);
const layoutedPositions = new Map(
nodes.map((node) => [node.id, node.position] as const),
);
@ -381,13 +392,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
(config) => config.kind === "seed",
);
if (!existing) {
return buildAddedNodeState(
state,
"seed",
type,
position,
openDialog,
);
return buildAddedNodeState(state, "seed", type, position, openDialog);
}
let nextSourceType: SeedSourceType = "hf";
if (type === "seed_local") {
@ -430,7 +435,10 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
[existing.id]: nextConfig,
},
nodes: updateNodeData(
state.nodes.map((node) => ({ ...node, selected: node.id === existing.id })),
state.nodes.map((node) => ({
...node,
selected: node.id === existing.id,
})),
existing.id,
nextConfig,
state.layoutDirection,
@ -444,7 +452,13 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
if (state.executionLocked) {
return state;
}
const added = buildAddedNodeState(state, "llm", type, position, openDialog);
const added = buildAddedNodeState(
state,
"llm",
type,
position,
openDialog,
);
const context = getAddedNodeContext(added);
if (!context) {
return added;
@ -495,9 +509,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
let { nodes, configs } = context;
let edges = state.edges;
const unboundModelConfigs = Object.values(configs).filter(
(config) =>
config.kind === "model_config" &&
!config.provider.trim(),
(config) => config.kind === "model_config" && !config.provider.trim(),
);
if (!position && unboundModelConfigs.length > 0) {
nodes = placeNodeNear(
@ -605,7 +617,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
let { nodes, configs } = context;
let edges = state.edges;
const unboundLlms = Object.values(configs).filter(
(config) => config.kind === "llm" && !(config.tool_alias?.trim()),
(config) => config.kind === "llm" && !config.tool_alias?.trim(),
);
if (!position && unboundLlms.length > 0) {
nodes = placeNodeNear(
@ -757,17 +769,15 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
if (cfg.kind !== "model_config" || cfg.provider !== providerName) {
continue;
}
if (nextIsLocal && !cfg.model.trim()) {
// external -> local: auto fill the placeholder model id so the
// config does not fail "model is required" validation.
configs = { ...configs, [cfgId]: { ...cfg, model: "local" } };
continue;
}
if (!nextIsLocal && cfg.model === "local") {
// local -> external: clear the placeholder so the user picks a
// real model id for the new external endpoint.
configs = { ...configs, [cfgId]: { ...cfg, model: "" } };
}
configs = {
...configs,
[cfgId]: {
...cfg,
model: "",
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
},
};
}
}
}

View file

@ -264,6 +264,8 @@ export type ModelConfig = {
kind: "model_config";
name: string;
model: string;
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant?: string;
provider: string;
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature?: string;

View file

@ -11,7 +11,6 @@ import {
isSemanticTargetHandle,
normalizeRecipeHandleId,
} from "../handles";
import { isSemanticRelation } from "./relations";
import {
isCategoryConfig,
isExpressionConfig,
@ -21,6 +20,7 @@ import {
VALIDATOR_OXC_CODE_LANGS,
VALIDATOR_SQL_CODE_LANGS,
} from "../validators/code-lang";
import { isSemanticRelation } from "./relations";
function buildTemplateWithRef(template: string, ref: string): string {
if (template.includes(ref)) {
@ -157,7 +157,10 @@ function isCompetingIncomingEdge(
return source.kind === "sampler" && source.sampler_type === "datetime";
}
function isModelSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
function isModelSemanticRelation(
source: NodeConfig,
target: NodeConfig,
): boolean {
return (
(source.kind === "model_provider" && target.kind === "model_config") ||
(source.kind === "model_config" && target.kind === "llm") ||
@ -181,7 +184,9 @@ function canApplyCodeLangToValidator(
if (normalized === "python") {
return true;
}
return VALIDATOR_SQL_CODE_LANGS.includes(normalized as typeof validator.code_lang);
return VALIDATOR_SQL_CODE_LANGS.includes(
normalized as typeof validator.code_lang,
);
}
function countHandleUsage(
@ -333,12 +338,8 @@ export function applyRecipeConnection(
if (!isValidRecipeConnection(connection, configs)) {
return { edges };
}
const initialSource = connection.source
? configs[connection.source]
: null;
const initialTarget = connection.target
? configs[connection.target]
: null;
const initialSource = connection.source ? configs[connection.source] : null;
const initialTarget = connection.target ? configs[connection.target] : null;
if (!(initialSource && initialTarget)) {
return { edges };
}
@ -386,17 +387,36 @@ export function applyRecipeConnection(
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
// Keep the model_config.model field in sync with provider mode when the
// link is changed via graph drag (the model-config dialog path has its
// own applyProviderChange helper that does the same thing).
// Keep model_config.provider in sync when a graph drag changes the link.
// Local providers now require an explicit selected load id; do not synthesize
// the legacy "local" placeholder. External relinks clear local-only GGUF
// metadata, while legacy placeholders are normalized back to empty.
const isSourceLocal = source.is_local === true;
let nextModel = target.model;
if (isSourceLocal && !nextModel.trim()) {
nextModel = "local";
} else if (!isSourceLocal && nextModel === "local") {
nextModel = "";
}
const next = { ...target, provider: source.name, model: nextModel };
const isLegacyLocalPlaceholder =
target.model.trim().toLowerCase() === "local";
const previousProviderName = target.provider.trim();
const previousProvider = Object.values(configs).find(
(config) =>
config.kind === "model_provider" &&
config.name === previousProviderName,
);
const wasLinkedToLocal =
previousProvider?.kind === "model_provider" &&
previousProvider.is_local === true;
const shouldClearModel =
isLegacyLocalPlaceholder ||
(isSourceLocal ? !wasLinkedToLocal : wasLinkedToLocal);
const next = {
...target,
provider: source.name,
...(shouldClearModel ? { model: "" } : {}),
...(shouldClearModel || !isSourceLocal
? {
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: undefined,
}
: {}),
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (source.kind === "model_config" && target.kind === "llm") {
@ -435,10 +455,9 @@ export function applyRecipeConnection(
// biome-ignore lint/style/useNamingConvention: api schema
target_columns: [source.name],
// biome-ignore lint/style/useNamingConvention: api schema
code_lang:
(
canUseCodeLangForTarget ? nextCodeLang : target.code_lang
) as typeof target.code_lang,
code_lang: (canUseCodeLangForTarget
? nextCodeLang
: target.code_lang) as typeof target.code_lang,
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}

View file

@ -1,15 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type {
ModelConfig,
ModelProviderConfig,
} from "../../../types";
import {
isRecord,
readNumberString,
readString,
} from "../helpers";
import type { ModelConfig, ModelProviderConfig } from "../../../types";
import { isRecord, readNumberString, readString } from "../helpers";
export function parseModelProvider(
provider: Record<string, unknown>,
@ -53,6 +46,8 @@ export function parseModelConfig(
kind: "model_config",
name,
model: readString(model.model) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: readString(model.gguf_variant) ?? undefined,
provider: readString(model.provider) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: readNumberString(inference.temperature),

View file

@ -54,55 +54,62 @@ export function buildModelProvider(
};
}
export function buildModelConfig(
function assignFiniteNumber(
target: Record<string, unknown>,
key: string,
rawValue: string | undefined,
transform: (value: number) => number = (value) => value,
): void {
const trimmed = rawValue?.trim();
if (!trimmed) {
return;
}
const parsed = Number(trimmed);
if (Number.isFinite(parsed)) {
target[key] = transform(parsed);
}
}
function buildInferenceParameters(
config: ModelConfig,
errors: string[],
): Record<string, unknown> {
const inference: Record<string, unknown> = {};
const temp = config.inference_temperature?.trim();
const topP = config.inference_top_p?.trim();
const maxTokens = config.inference_max_tokens?.trim();
const timeout = config.inference_timeout?.trim();
assignFiniteNumber(inference, "temperature", config.inference_temperature);
assignFiniteNumber(inference, "top_p", config.inference_top_p);
assignFiniteNumber(inference, "max_tokens", config.inference_max_tokens);
assignFiniteNumber(
inference,
"timeout",
config.inference_timeout,
Math.trunc,
);
const extraBody = parseJsonObject(
config.inference_extra_body,
`Model ${config.name} inference extra_body`,
errors,
);
if (temp) {
const parsed = Number(temp);
if (Number.isFinite(parsed)) {
inference.temperature = parsed;
}
}
if (topP) {
const parsed = Number(topP);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.top_p = parsed;
}
}
if (maxTokens) {
const parsed = Number(maxTokens);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.max_tokens = parsed;
}
}
if (timeout) {
const parsed = Number(timeout);
if (Number.isFinite(parsed)) {
inference.timeout = Math.trunc(parsed);
}
}
if (extraBody) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.extra_body = extraBody;
}
return inference;
}
export function buildModelConfig(
config: ModelConfig,
errors: string[],
): Record<string, unknown> {
const inference = buildInferenceParameters(config, errors);
const ggufVariant = config.gguf_variant?.trim();
return {
alias: config.name,
model: config.model,
// biome-ignore lint/style/useNamingConvention: api schema
gguf_variant: ggufVariant || undefined,
provider: config.provider || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
inference_parameters:

View file

@ -54,7 +54,9 @@ export function validateTimedeltaConfigs(
}
const reference = config.reference_column_name?.trim() ?? "";
if (!reference) {
errors.push(`Timedelta ${config.name}: reference datetime column required.`);
errors.push(
`Timedelta ${config.name}: reference datetime column required.`,
);
continue;
}
const parent = nameToConfig.get(reference);
@ -63,7 +65,9 @@ export function validateTimedeltaConfigs(
parent.kind !== "sampler" ||
parent.sampler_type !== "datetime"
) {
errors.push(`Timedelta ${config.name}: reference '${reference}' must be datetime.`);
errors.push(
`Timedelta ${config.name}: reference '${reference}' must be datetime.`,
);
}
}
}
@ -91,9 +95,18 @@ export function validateModelConfigProviders(
const provider = config.provider.trim();
const alias = config.name;
const isLocal = localProviderNames.has(provider);
// Local providers do not require a real model id - the loaded Chat
// model is used regardless of what gets sent in the payload.
if (!isLocal && modelAliases.has(alias) && !config.model.trim()) {
const isUsed = modelAliases.has(alias);
const model = config.model.trim();
const isLegacyLocalPlaceholder = model.toLowerCase() === "local";
if (!isLocal && isUsed && isLegacyLocalPlaceholder) {
errors.push(`Model config ${alias}: model is required.`);
continue;
}
if (isLocal && isUsed && !model) {
errors.push(`Model config ${alias}: choose a local model.`);
}
if (!isLocal && isUsed && !model) {
errors.push(`Model config ${alias}: model is required.`);
}
if (provider && !modelProviderNames.has(provider)) {
@ -121,7 +134,9 @@ export function validateUsedProviders(
errors.push(`Model provider ${provider.name}: endpoint is required.`);
}
if (!provider.provider_type.trim()) {
errors.push(`Model provider ${provider.name}: provider_type is required.`);
errors.push(
`Model provider ${provider.name}: provider_type is required.`,
);
}
}
}
@ -145,7 +160,9 @@ export function validateValidatorConfigs(
continue;
}
if (targetConfig.kind !== "llm" || targetConfig.llm_type !== "code") {
errors.push(`Validator ${config.name}: target '${target}' must be LLM Code.`);
errors.push(
`Validator ${config.name}: target '${target}' must be LLM Code.`,
);
continue;
}
if (