From 31ac558a73fd76bc200a4a22f10ceae5a73b9ae4 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 26 May 2026 11:37:24 +0200 Subject: [PATCH] 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> --- studio/backend/core/data_recipe/service.py | 41 +- studio/backend/models/inference.py | 6 +- studio/backend/routes/data_recipe/jobs.py | 121 +++- studio/backend/routes/inference.py | 16 +- studio/frontend/package.json | 4 +- studio/frontend/src/features/chat/index.ts | 8 + .../features/chat/presets/preset-policy.ts | 2 +- .../frontend/src/features/chat/types/api.ts | 3 +- .../components/inline/inline-model.tsx | 80 ++- .../models/local-recipe-model-selector.tsx | 644 ++++++++++++++++++ .../dialogs/models/model-config-dialog.tsx | 86 ++- .../easy/github-crawler-easy-view.tsx | 85 ++- .../recipe-studio/executions/tracker.ts | 101 ++- .../hooks/use-recipe-executions.ts | 565 ++++++++++++--- .../stores/helpers/reference-sync.ts | 20 +- .../recipe-studio/stores/recipe-studio.ts | 98 +-- .../src/features/recipe-studio/types/index.ts | 2 + .../utils/graph/recipe-graph-connection.ts | 65 +- .../utils/import/parsers/model-parser.ts | 13 +- .../utils/payload/builders-model.ts | 73 +- .../recipe-studio/utils/payload/validate.ts | 31 +- 21 files changed, 1714 insertions(+), 350 deletions(-) create mode 100644 studio/frontend/src/features/recipe-studio/dialogs/models/local-recipe-model-selector.tsx diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py index 7fff36aefd..b4ec0ccd94 100644 --- a/studio/backend/core/data_recipe/service.py +++ b/studio/backend/core/data_recipe/service.py @@ -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( diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index de2c166d91..0af9425fdc 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -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" diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py index da6416e324..107a1657f3 100644 --- a/studio/backend/routes/data_recipe/jobs.py +++ b/studio/backend/routes/data_recipe/jobs.py @@ -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 diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9621d18801..a156f2397c 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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, diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 061a2b517d..83b1fd96f9 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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", diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 4726b11fcf..883dea3f3a 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -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, diff --git a/studio/frontend/src/features/chat/presets/preset-policy.ts b/studio/frontend/src/features/chat/presets/preset-policy.ts index ae8c1f41ae..4efbb74f11 100644 --- a/studio/frontend/src/features/chat/presets/preset-policy.ts +++ b/studio/frontend/src/features/chat/presets/preset-policy.ts @@ -248,7 +248,7 @@ interface BackendInferenceDefaults { export interface BackendInferenceEnvelope { is_gguf?: boolean; context_length?: number | null; - inference?: BackendInferenceDefaults; + inference?: BackendInferenceDefaults | null; } export function mergeBackendRecommendedInference({ diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index b7a61d24b6..5238875b71 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -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"; diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx index 16e99f4fae..1d98d18b56 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx @@ -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 }) + } /> @@ -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(); 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 (
@@ -82,12 +94,38 @@ export function InlineModel(props: InlineModelProps): ReactElement { /> - props.onUpdate({ model: event.target.value })} - /> + {isLinkedToLocal ? ( + + props.onUpdate({ + model, + // biome-ignore lint/style/useNamingConvention: api schema + gguf_variant: variant ?? undefined, + }) + } + /> + ) : ( + + props.onUpdate({ + model: event.target.value, + // biome-ignore lint/style/useNamingConvention: api schema + gguf_variant: undefined, + }) + } + /> + )} 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(null); + const [defaultVariant, setDefaultVariant] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ + Loading quantizations... +
+ ); + } + + if (error) { + return
{error}
; + } + + if (!sortedVariants || sortedVariants.length === 0) { + return ( +
+ No GGUF quantizations found for this model. +
+ ); + } + + return ( +
+
+ Quantization +
+
+ {sortedVariants.map((variant) => { + const selected = selectedVariant === variant.quant; + return ( + + ); + })} +
+
+ ); +} + +type SelectorTriggerProps = ComponentPropsWithoutRef<"button"> & { + value: string; + selectedModel: LocalModelInfo | null; + ggufVariant?: string | null; + inputId?: string; + disabled: boolean; + compact: boolean; + className?: string; +}; + +const SelectorTrigger = forwardRef( + function SelectorTrigger( + { + value, + selectedModel, + ggufVariant, + inputId, + disabled, + compact, + className, + ...triggerProps + }, + ref, + ): ReactElement { + const selected = getSelectedModelSummary(value, selectedModel, ggufVariant); + + return ( + + ); + }, +); + +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 ( +
+ + {expanded ? ( + onSelectVariant(model.id, variant)} + /> + ) : null} +
+ ); +} + +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 ( +
+ + Scanning local models... +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (models.length === 0) { + return ( +
+

No local models found.

+

+ Download a model or add a scan folder from Chat, then refresh this + list. +

+ + Open Chat model picker + +
+ ); + } + + return ( +
+ {models.map((model) => ( + + ))} +
+ ); +} + +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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedModelId, setExpandedModelId] = useState(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 ( + + + + + +
+
+
+ setQuery(event.target.value)} + placeholder="Filter local models" + className="h-8 flex-1" + autoFocus={true} + /> + +
+
+ +
event.stopPropagation()} + > + +
+
+
+
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx index 368ae08acb..68f912bc57 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx @@ -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(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); }; - // 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

- 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.

@@ -144,15 +155,48 @@ export function ModelConfigDialog({ - 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 ? ( + + onUpdate({ + model, + // biome-ignore lint/style/useNamingConvention: api schema + gguf_variant: variant ?? undefined, + }) + } + /> + ) : ( + + onUpdate({ + model: event.target.value, + // biome-ignore lint/style/useNamingConvention: api schema + gguf_variant: undefined, + }) + } + /> + )} + {isLinkedToLocal ? ( +

+ Recipes will load this model automatically. GGUF quantization is + saved with the preset. +

+ ) : null}
@@ -250,8 +294,12 @@ export function ModelConfigDialog({ } />
-
@@ -149,15 +187,28 @@ export function GithubCrawlerEasyView({
- 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 ? ( + + ) : ( + handleModelChange(event.target.value)} + placeholder="unsloth/gemma-4-E2B-it-GGUF" + disabled={!modelConfig} + /> + )}
diff --git a/studio/frontend/src/features/recipe-studio/executions/tracker.ts b/studio/frontend/src/features/recipe-studio/executions/tracker.ts index d83f662fcf..97e70c66c4 100644 --- a/studio/frontend/src/features/recipe-studio/executions/tracker.ts +++ b/studio/frontend/src/features/recipe-studio/executions/tracker.ts @@ -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 { +}: TrackRecipeExecutionParams): Promise { let done = false; let lastStatus: RecipeExecutionStatus = initialExecution.status; let completedEventPayload: Record | 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) + completedPayload && + typeof completedPayload.processor_artifacts === "object" && + completedPayload.processor_artifacts !== null + ? (completedPayload.processor_artifacts as Record) : 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 }; } diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts index c3da5b1999..19a6a3004d 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts @@ -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 { +const GGUF_MODEL_PATTERN = /gguf/i; + +function collectUsedLlmModelAliases(payload: RecipePayload): Set { + const columns = Array.isArray(payload.recipe.columns) + ? payload.recipe.columns + : []; + const aliases = new Set(); + 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 { const providers = Array.isArray(payload.recipe.model_providers) - ? (payload.recipe.model_providers as Array>) + ? (payload.recipe.model_providers as Record[]) : []; const localProviderNames = new Set(); - 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, +): Record[] { + const usedAliases = collectUsedLlmModelAliases(payload); + if (usedAliases.size === 0) { + return []; } const modelConfigs = Array.isArray(payload.recipe.model_configs) - ? (payload.recipe.model_configs as Array>) + ? 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, +): 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[], +): LocalModelLoadPlan | null { + const selections = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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) | 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) | null; }): Promise => { - 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) | 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[0], + ): Promise => { + 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 => { + const runPreview = useCallback((): Promise => { return runWithValidation("preview", previewRows, null); }, [previewRows, runWithValidation]); - const runFull = useCallback(async (): Promise => { + const runFull = useCallback((): Promise => { return runWithValidation("full", fullRows, fullRunName); }, [fullRows, fullRunName, runWithValidation]); - const runFromDialog = useCallback(async (): Promise => { + const runFromDialog = useCallback((): Promise => { 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 => { 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, diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts index 56634b84c8..dd5e12233c 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts @@ -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), + ); } diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts index 036cc94082..52ce1329bf 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -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): boolean { +function isModelSemanticEdge( + edge: Edge, + configs: Record, +): boolean { const source = configs[edge.source]; const target = configs[edge.target]; return Boolean( @@ -315,12 +322,16 @@ export const useRecipeStudioStore = create((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((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((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((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((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((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((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, + }, + }; } } } diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts index 9c720a06d3..b8ed13f70b 100644 --- a/studio/frontend/src/features/recipe-studio/types/index.ts +++ b/studio/frontend/src/features/recipe-studio/types/index.ts @@ -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; diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts index 4fa70b3e3a..059ddf186f 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts @@ -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 } }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts index 15ecf39a7b..6b3df846a1 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts @@ -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, @@ -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), diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts index 14e0faa5cc..1575919705 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts @@ -54,55 +54,62 @@ export function buildModelProvider( }; } -export function buildModelConfig( +function assignFiniteNumber( + target: Record, + 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 { const inference: Record = {}; - 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 { + 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: diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts index 7e72e8d919..a3b3763291 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts @@ -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 (