* 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.
194 lines
7 KiB
Python
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)
|