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:
parent
f364b08b6c
commit
31ac558a73
21 changed files with 1711 additions and 347 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ interface BackendInferenceDefaults {
|
|||
export interface BackendInferenceEnvelope {
|
||||
is_gguf?: boolean;
|
||||
context_length?: number | null;
|
||||
inference?: BackendInferenceDefaults;
|
||||
inference?: BackendInferenceDefaults | null;
|
||||
}
|
||||
|
||||
export function mergeBackendRecommendedInference({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue