unsloth/studio/backend/core/data_recipe/service.py
Wasim Yousef Said 50dede11cc
Allow non-LLM recipes to run and move Data tab first in executions (#4805)
* feat: allow non-LLM recipes to run without provider block

* feat: reorder execution tabs and add generation-aware data tab empty state

* fix: add accessibility attrs to data tab spinner and use literal ellipsis

* fix(studio): use shared spinner, stub provider, and hide unused LLM metrics

Backend: inject stub model provider for sampler-only recipes so
DataDesigner init does not reject empty provider lists.

Frontend: use shared Spinner component, hide LLM columns metric
and model usage card when recipe has no LLM columns.

* Fix tab reset and terminal auto-scroll regressions for PR #4805

Reset detailTab to "data" when switching between executions so
the Data tab default is applied consistently, not only on first
mount. Also add detailTab to the terminal scroll effect deps so
auto-scroll-to-bottom fires when the user opens the Overview tab
after landing on Data.

* Guard terminal scroll reset to only fire on Overview tab

The previous scroll effect ran on every tab switch, which could
reset the user's manual scroll position if they scrolled up in
the terminal and briefly switched tabs. Now the scroll-to-bottom
and sticky-bottom reset only fires when navigating to the
Overview tab.

* Use None for stub provider api_key instead of literal string

The stub ModelProvider that satisfies the DataDesigner registry
for non-LLM recipes should not carry a fake credential string.
Using None avoids sending an Authorization header if the provider
is ever inadvertently invoked.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-04-03 05:37:26 -07:00

316 lines
10 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import base64
import io
import os
from pathlib import Path
from typing import Any
from .jsonable import to_jsonable
from .local_callable_validators import (
register_oxc_local_callable_validators,
split_oxc_local_callable_validators,
)
_IMAGE_CONTEXT_PATCHED = False
def _encode_bytes_to_base64(value: bytes | bytearray) -> str:
return base64.b64encode(bytes(value)).decode("utf-8")
def _load_image_file_to_base64(
path_value: str, *, base_path: str | None = None
) -> str | None:
try:
path = Path(path_value)
candidates: list[Path] = []
if path.is_absolute():
candidates.append(path)
else:
if base_path:
candidates.append(Path(base_path) / path)
candidates.append(Path.cwd() / path)
for candidate in candidates:
if not candidate.exists() or not candidate.is_file():
continue
with candidate.open("rb") as f:
return _encode_bytes_to_base64(f.read())
except (OSError, TypeError, ValueError):
return None
return None
def _pil_image_to_base64(value: Any) -> str | None:
try:
from PIL.Image import Image as PILImage # type: ignore
except ImportError:
return None
if not isinstance(value, PILImage):
return None
buffer = io.BytesIO()
image_format = str(getattr(value, "format", "") or "").upper()
if image_format not in {"PNG", "JPEG", "JPG", "WEBP", "GIF"}:
image_format = "PNG"
value.save(buffer, format = image_format)
return _encode_bytes_to_base64(buffer.getvalue())
def _normalize_image_context_value(value: Any, *, base_path: str | None = None) -> Any:
if isinstance(value, str):
return value
if isinstance(value, (bytes, bytearray)):
return _encode_bytes_to_base64(value)
pil_base64 = _pil_image_to_base64(value)
if pil_base64 is not None:
return pil_base64
if isinstance(value, dict):
url = value.get("url")
if isinstance(url, str):
return url
image_url = value.get("image_url")
if isinstance(image_url, str):
return image_url
if isinstance(image_url, dict):
nested_url = image_url.get("url")
if isinstance(nested_url, str):
return nested_url
inline_data = value.get("data")
if isinstance(inline_data, str):
return inline_data
raw_bytes = value.get("bytes")
if isinstance(raw_bytes, (bytes, bytearray)):
return _encode_bytes_to_base64(raw_bytes)
if isinstance(raw_bytes, str) and raw_bytes.strip():
return raw_bytes
path_value = value.get("path")
if isinstance(path_value, str) and path_value.strip():
if as_base64 := _load_image_file_to_base64(path_value, base_path = base_path):
return as_base64
return path_value
return value
def _apply_data_designer_image_context_patch() -> None:
global _IMAGE_CONTEXT_PATCHED
if _IMAGE_CONTEXT_PATCHED:
return
try:
from data_designer.config.models import ImageContext
except ImportError:
return
if getattr(ImageContext, "_unsloth_image_context_patch_applied", False):
_IMAGE_CONTEXT_PATCHED = True
return
original_auto_resolve = ImageContext._auto_resolve_context_value
def _patched_auto_resolve(
self: Any, context_value: Any, base_path: str | None
) -> Any:
normalized = _normalize_image_context_value(context_value, base_path = base_path)
return original_auto_resolve(self, normalized, base_path)
ImageContext._auto_resolve_context_value = _patched_auto_resolve
setattr(ImageContext, "_unsloth_image_context_patch_applied", True)
_IMAGE_CONTEXT_PATCHED = True
def build_model_providers(recipe: dict[str, Any]):
from data_designer.config.models import ModelProvider
providers: list[ModelProvider] = []
for provider in recipe.get("model_providers", []):
api_key = provider.get("api_key")
api_key_env = provider.get("api_key_env")
if not api_key and api_key_env:
api_key = os.getenv(api_key_env)
providers.append(
ModelProvider(
name = provider["name"],
endpoint = provider["endpoint"],
provider_type = provider.get("provider_type", "openai"),
api_key = api_key,
extra_headers = provider.get("extra_headers"),
extra_body = provider.get("extra_body"),
)
)
return providers
def _recipe_has_llm_columns(recipe: dict[str, Any]) -> bool:
for column in recipe.get("columns", []):
if not isinstance(column, dict):
continue
column_type = column.get("column_type")
if isinstance(column_type, str) and column_type.startswith("llm-"):
return True
return False
def _validate_recipe_runtime_support(
recipe: dict[str, Any],
model_providers: list[Any],
) -> None:
if _recipe_has_llm_columns(recipe) and not model_providers:
raise ValueError("Add a Provider connection block before running this recipe.")
def build_mcp_providers(
recipe: dict[str, Any],
) -> list:
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider
providers: list[MCPProvider | LocalStdioMCPProvider] = []
for provider in recipe.get("mcp_providers", []):
if not isinstance(provider, dict):
continue
provider_type = provider.get("provider_type")
if provider_type == "stdio":
env = provider.get("env")
if not isinstance(env, dict):
env = {}
args = provider.get("args")
if not isinstance(args, list):
args = []
providers.append(
LocalStdioMCPProvider(
name = str(provider.get("name", "")),
command = str(provider.get("command", "")),
args = [str(value) for value in args],
env = {str(key): str(value) for key, value in env.items()},
)
)
continue
if provider_type in {"sse", "streamable_http"}:
api_key = provider.get("api_key")
api_key_env = provider.get("api_key_env")
if not api_key and api_key_env:
api_key = os.getenv(str(api_key_env))
providers.append(
MCPProvider(
name = str(provider.get("name", "")),
endpoint = str(provider.get("endpoint", "")),
provider_type = str(provider_type),
api_key = str(api_key) if api_key else None,
)
)
return providers
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
recipe_core = {
key: value
for key, value in recipe.items()
if key not in {"model_providers", "mcp_providers"}
}
recipe_core, oxc_local_callable_specs = split_oxc_local_callable_validators(
recipe_core
)
builder = DataDesignerConfigBuilder.from_config({"data_designer": recipe_core})
register_oxc_local_callable_validators(
builder = builder,
specs = oxc_local_callable_specs,
)
# DataDesignerConfigBuilder.from_config currently skips processors.
# Re-attach explicitly so drop_columns/schema_transform survive API payload.
for processor in recipe_core.get("processors") or []:
if not isinstance(processor, dict):
continue
processor_type_raw = processor.get("processor_type")
if not isinstance(processor_type_raw, str):
continue
kwargs = {k: v for k, v in processor.items() if k != "processor_type"}
builder.add_processor(
processor_type = ProcessorType(processor_type_raw),
**kwargs,
)
return builder
def create_data_designer(
recipe: dict[str, Any],
*,
artifact_path: str | None = None,
):
_apply_data_designer_image_context_patch()
from data_designer.interface.data_designer import DataDesigner
model_providers = build_model_providers(recipe)
_validate_recipe_runtime_support(recipe, model_providers)
# DataDesigner requires at least one model provider in its registry even
# 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
model_providers = [
ModelProvider(
name = "_unused",
endpoint = "http://localhost",
provider_type = "openai",
api_key = None,
)
]
return DataDesigner(
artifact_path = artifact_path,
model_providers = model_providers,
mcp_providers = build_mcp_providers(recipe),
)
def validate_recipe(recipe: dict[str, Any]) -> None:
builder = build_config_builder(recipe)
designer = create_data_designer(recipe)
designer.validate(builder)
def preview_recipe(
recipe: dict[str, Any],
num_records: int,
) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]:
builder = build_config_builder(recipe)
designer = create_data_designer(recipe)
results = designer.preview(builder, num_records = num_records)
dataset: list[dict[str, Any]] = []
if results.dataset is not None:
raw_rows = results.dataset.to_dict(orient = "records")
dataset = [to_jsonable(row) for row in raw_rows]
artifacts = (
None
if results.processor_artifacts is None
else to_jsonable(results.processor_artifacts)
)
analysis = (
None
if results.analysis is None
else to_jsonable(results.analysis.model_dump(mode = "json"))
)
return dataset, artifacts, analysis