unsloth/studio/backend/routes/data_recipe/validate.py
Daniel Han a5615426a5
Studio: fix 7 failing studio_unit_tests on main (#5216)
* Studio: fix 4 failing studio_unit_tests on main

Three of the failing tests had drifted from production:

1. test_health_response_reports_desktop_capability_fields stubbed
   `routes` with a SimpleNamespace that omitted `inference_studio_router`,
   so importing studio.backend.main raised ImportError. Add the missing
   router stub.

2. test_local_recipe_token_preserves_desktop_marker and
   test_local_recipe_token_keeps_web_marker_absent decoded the local
   provider's api_key as a JWT, but _inject_local_providers now mints
   a unified sk-unsloth-* internal API key (not a forwarded JWT), so
   jwt.decode raised "Not enough segments". Renamed and rewrote both
   tests to validate the API-key contract: starts with
   storage.API_KEY_PREFIX and authenticates via get_current_subject as
   the real admin user. The web vs desktop distinction is irrelevant
   at this layer because the unified API-key path does not carry
   session flags.

The fourth failure was a real production bug:

3. test_github_validate_skips_live_access_with_honest_note expected
   github-seed validation to return valid=True per
   _GITHUB_VALIDATE_NOTE ("GitHub access and rate limits are checked
   when the run starts"). The validate route called
   build_config_builder which lazy-imports the optional data_designer
   module; when it is missing, the bare except blocked the recipe.
   Catch ImportError specifically and treat it as a deferred check,
   matching the documented intent.

Verified all 4 tests pass and the rest of studio/backend/tests still
pass (608 total, with the only remaining failures being environment
specific: 4 GPU-aware tests on a no-GPU host and 1 Anthropic-API
smoke test, both unrelated).

* Studio: fix 3 test_gpu_selection route tests after load_model signature change

`routes/inference.load_model` gained a `fastapi_request: Request`
positional argument (used to read `app.state.llama_parallel_slots`
inside the GGUF path), but the three TestRouteErrors cases that
exercise the early validation path were not updated and failed with
`TypeError: load_model() missing 1 required positional argument:
'fastapi_request'`.

Pass a SimpleNamespace mock that satisfies the attribute path the
production code reads. The validation under test fires before the
mock is consumed, but supplying the realistic shape protects against
regressions if the validation order changes.

Affected tests:
- test_inference_route_rejects_gpu_ids_for_gguf
- test_inference_route_returns_400_for_invalid_gpu_ids
- test_inference_route_returns_400_for_uuid_parent_visibility_gpu_ids

* Studio: address review feedback on validate.py ImportError handling

Two reviewers flagged the ImportError bypass added in b0d33cf:

- chatgpt-codex-connector[bot]: catching bare ImportError marks recipes
  as valid even when build_config_builder fails for unrelated import
  problems (broken internal imports, missing transitive deps after a
  version bump), hiding real regressions until run start.
- gemini-code-assist[bot]: silent pass discourages troubleshooting;
  the deferred-validation case should be logged at debug level.

Tighten the bypass to ModuleNotFoundError where the missing module name
starts with "data_designer". Other ImportErrors propagate to the outer
handler and surface as validation failures, restoring the visibility
the reviewers asked for. Add a debug-level log entry that names the
missing module so operators can trace why validation deferred.
2026-04-28 22:43:44 -07:00

194 lines
7 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
"""Validation endpoints for data recipe."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException
from core.data_recipe.service import (
build_config_builder,
create_data_designer,
validate_recipe,
)
from loggers import get_logger
from models.data_recipe import RecipePayload, ValidateError, ValidateResponse
logger = get_logger(__name__)
router = APIRouter()
_GITHUB_VALIDATE_NOTE = "Recipe shape is valid. GitHub access and rate limits are checked when the run starts."
_GITHUB_ITEM_TYPES = {"issues", "pulls", "commits"}
def _github_seed_source(recipe: dict[str, Any]) -> dict[str, Any] | None:
seed_config = recipe.get("seed_config")
if not isinstance(seed_config, dict):
return None
source = seed_config.get("source")
if not isinstance(source, dict) or source.get("seed_type") != "github_repo":
return None
return source
def _validate_github_seed_static(source: dict[str, Any]) -> list[ValidateError]:
errors: list[ValidateError] = []
repos = source.get("repos")
if not isinstance(repos, list) or not repos:
errors.append(ValidateError(message = "GitHub seed requires at least one repo."))
else:
for repo in repos:
if not isinstance(repo, str) or not repo.strip() or "/" not in repo:
errors.append(
ValidateError(message = "GitHub repos must be owner/name strings.")
)
break
item_types = source.get("item_types")
if not isinstance(item_types, list) or not item_types:
errors.append(
ValidateError(message = "GitHub seed requires at least one item type.")
)
else:
invalid_items = [item for item in item_types if item not in _GITHUB_ITEM_TYPES]
if invalid_items:
errors.append(
ValidateError(
message = "GitHub item types must be issues, pulls, or commits."
)
)
try:
limit = int(source.get("limit"))
except (TypeError, ValueError):
limit = 0
if limit < 1 or limit > 5000:
errors.append(ValidateError(message = "GitHub limit must be from 1 to 5000."))
return errors
def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
try:
from data_designer.engine.compiler import (
_add_internal_row_id_column_if_needed,
_get_allowed_references,
_resolve_and_add_seed_columns,
)
from data_designer.engine.validation import (
ViolationLevel,
validate_data_designer_config,
)
except ImportError:
return []
try:
builder = build_config_builder(recipe)
designer = create_data_designer(recipe)
resource_provider = designer._create_resource_provider( # type: ignore[attr-defined]
"validate-configuration",
builder,
)
config = builder.build()
_resolve_and_add_seed_columns(config, resource_provider.seed_reader)
_add_internal_row_id_column_if_needed(config)
violations = validate_data_designer_config(
columns = config.columns,
processor_configs = config.processors or [],
allowed_references = _get_allowed_references(config),
)
except (TypeError, ValueError, AttributeError):
return []
errors: list[ValidateError] = []
for violation in violations:
if violation.level != ViolationLevel.ERROR:
continue
code = getattr(violation.type, "value", None)
path = violation.column if violation.column else None
message = str(violation.message).strip() or "Validation failed."
errors.append(
ValidateError(
message = message,
path = path,
code = code,
)
)
return errors
def _patch_local_providers(recipe: dict[str, Any]) -> None:
"""Strip is_local and fill a dummy endpoint so validation doesn't choke.
Uses a strict `is True` check to match _inject_local_providers in
jobs.py - malformed payloads with truthy but non-boolean is_local
values should not be treated as local.
"""
for provider in recipe.get("model_providers", []):
if not isinstance(provider, dict):
continue
if provider.pop("is_local", None) is True:
provider["endpoint"] = "http://127.0.0.1"
@router.post("/validate", response_model = ValidateResponse)
def validate(payload: RecipePayload) -> ValidateResponse:
recipe = payload.recipe
if not recipe.get("columns"):
return ValidateResponse(
valid = False,
errors = [ValidateError(message = "Recipe must include columns.")],
)
_patch_local_providers(recipe)
github_source = _github_seed_source(recipe)
if github_source is not None:
static_errors = _validate_github_seed_static(github_source)
if static_errors:
return ValidateResponse(valid = False, errors = static_errors)
try:
build_config_builder(recipe)
except ModuleNotFoundError as exc:
# data_designer is an optional runtime dep. Static validation
# already passed; live access + full config validation are
# deferred to run start (per _GITHUB_VALIDATE_NOTE), so a missing
# optional import at validate time should not block the recipe.
# Restrict the bypass to the data_designer module specifically so
# other ImportErrors (e.g. broken internal imports or missing
# transitive deps after a package upgrade) still surface as
# validation failures instead of being silently swallowed.
if not (exc.name or "").startswith("data_designer"):
raise
logger.debug(
"data_designer not installed; deferring full config "
"validation to run start",
missing_module = exc.name,
)
except Exception as exc:
detail = str(exc).strip() or "Validation failed."
return ValidateResponse(
valid = False,
errors = [ValidateError(message = detail)],
raw_detail = detail,
)
return ValidateResponse(valid = True, raw_detail = _GITHUB_VALIDATE_NOTE)
try:
validate_recipe(recipe)
except RuntimeError as exc:
raise HTTPException(status_code = 503, detail = str(exc)) from exc
except Exception as exc:
detail = str(exc).strip() or "Validation failed."
parsed_errors = _collect_validation_errors(recipe)
return ValidateResponse(
valid = False,
errors = parsed_errors or [ValidateError(message = detail)],
raw_detail = detail,
)
return ValidateResponse(valid = True)