# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Load inference params (temperature, top_p, top_k, min_p) from model YAML, family defaults, or default.yaml.""" from pathlib import Path from typing import Dict, Any, Optional from functools import lru_cache import json import math import os import yaml import structlog from loggers import get_logger from utils.models.model_config import load_model_defaults from utils.paths import is_local_path, normalize_path logger = get_logger(__name__) # ── Family-based inference defaults (loaded once, cached) ────────────── _FAMILY_DEFAULTS: Optional[Dict[str, Any]] = None _FAMILY_PATTERNS: Optional[list] = None def _load_family_defaults(): """Load and cache inference_defaults.json.""" global _FAMILY_DEFAULTS, _FAMILY_PATTERNS if _FAMILY_DEFAULTS is not None: return json_path = ( Path(__file__).parent.parent.parent / "assets" / "configs" / "inference_defaults.json" ) try: with open(json_path, "r", encoding = "utf-8") as f: data = json.load(f) _FAMILY_DEFAULTS = data.get("families", {}) _FAMILY_PATTERNS = data.get("patterns", []) except Exception as e: logger.warning(f"Failed to load inference_defaults.json: {e}") _FAMILY_DEFAULTS = {} _FAMILY_PATTERNS = [] def get_family_inference_params(model_id: str) -> Dict[str, Any]: """Look up recommended inference params by model family. Extracts the family from the identifier (e.g. "unsloth/Qwen3.5-9B-GGUF" -> "qwen3.5") and returns matching params from inference_defaults.json, or {}. """ _load_family_defaults() if not _FAMILY_PATTERNS or not _FAMILY_DEFAULTS: return {} # Normalize: lowercase, strip org prefix. normalized = model_id.lower() if "/" in normalized: normalized = normalized.split("/", 1)[1] # Match patterns (ordered longest-match-first in the JSON). for pattern in _FAMILY_PATTERNS: if pattern in normalized: params = _FAMILY_DEFAULTS.get(pattern, {}) if params: return dict(params) return {} def _has_specific_yaml(model_identifier: str) -> bool: """Check if a model has its own YAML config (not just default.yaml).""" from utils.models.model_config import _REVERSE_MODEL_MAPPING script_dir = Path(__file__).parent.parent.parent defaults_dir = script_dir / "assets" / "configs" / "model_defaults" if model_identifier.lower() in _REVERSE_MODEL_MAPPING: return True # For local paths, normalize backslashes so Path().parts splits correctly, # then match the last 1-2 components against the registry (mirrors load_model_defaults). _is_local = is_local_path(model_identifier) _normalized = normalize_path(model_identifier) if _is_local else model_identifier if _is_local: parts = Path(_normalized).parts for depth in (2, 1): if len(parts) >= depth: suffix = "/".join(parts[-depth:]) if suffix.lower() in _REVERSE_MODEL_MAPPING: return True _lookup = Path(_normalized).name else: _lookup = model_identifier # Exact filename match (basename for local paths; absolute paths break rglob on Windows). model_filename = _lookup.replace("/", "_") + ".yaml" for config_path in defaults_dir.rglob(model_filename): if config_path.is_file(): return True return False def load_inference_config(model_identifier: str) -> Dict[str, Any]: """Load inference params for a model. Priority: model-specific YAML, then family defaults (inference_defaults.json), then default.yaml. Returns a dict of temperature/top_p/top_k/min_p/etc. """ model_defaults = load_model_defaults(model_identifier) # default.yaml for fallback values. script_dir = Path(__file__).parent.parent.parent defaults_dir = script_dir / "assets" / "configs" / "model_defaults" default_config_path = defaults_dir / "default.yaml" default_inference = {} if default_config_path.exists(): try: with open(default_config_path, "r", encoding = "utf-8") as f: default_config = yaml.safe_load(f) or {} default_inference = default_config.get("inference", {}) except Exception as e: logger.warning(f"Failed to load default.yaml: {e}") # Family-based defaults from inference_defaults.json. family_params = get_family_inference_params(model_identifier) model_inference = model_defaults.get("inference", {}) # Model's own YAML beats family defaults; if it only fell back to # default.yaml, family defaults win. has_own_yaml = _has_specific_yaml(model_identifier) def _get_param(key, hardcoded_default): if has_own_yaml: # Model-specific YAML wins, then family fills gaps, then default.yaml. val = model_inference.get(key) if val is not None and isinstance(val, (int, float)): return val if key in family_params: return family_params[key] return default_inference.get(key, hardcoded_default) else: # No model-specific YAML: family wins, then default.yaml. if key in family_params: return family_params[key] return default_inference.get(key, hardcoded_default) inference_config = { "temperature": _get_param("temperature", 0.7), "top_p": _get_param("top_p", 0.95), "top_k": _get_param("top_k", -1), "min_p": _get_param("min_p", 0.01), "presence_penalty": _get_param("presence_penalty", 0.0), "trust_remote_code": model_inference.get( "trust_remote_code", default_inference.get("trust_remote_code", False) ), } return inference_config # ── Effective sampling resolution for `unsloth run` / `unsloth start` ────────── # # Per-model recommended sampling is applied to a request only for the fields the # client omitted; an operator can pin a field from the CLI via UNSLOTH_SAMPLING_* # (a hard override that wins even over an explicit client value). Precedence per # field: operator pin -> client explicit -> per-model recommendation -> the static # schema default (mirroring ChatCompletionRequest, so behavior is unchanged when # nothing is recommended or pinned). # field -> (env var, static default, min, max, is_int) _SAMPLING_FIELDS = { "temperature": ("UNSLOTH_SAMPLING_TEMPERATURE", 0.6, 0.0, 2.0, False), "top_p": ("UNSLOTH_SAMPLING_TOP_P", 0.95, 0.0, 1.0, False), "top_k": ("UNSLOTH_SAMPLING_TOP_K", 20, -1, 100, True), "min_p": ("UNSLOTH_SAMPLING_MIN_P", 0.01, 0.0, 1.0, False), "repetition_penalty": ("UNSLOTH_SAMPLING_REPETITION_PENALTY", 1.0, 1.0, 2.0, False), "presence_penalty": ("UNSLOTH_SAMPLING_PRESENCE_PENALTY", 0.0, 0.0, 2.0, False), } # Public, ordered tuple of the sampling fields callers resolve. SAMPLING_FIELD_NAMES = tuple(_SAMPLING_FIELDS) # Fields the Studio Chat UI adopts as *per-model recommendations* from the backend # `.inference` block. Its frontend `mergeBackendRecommendedInference` # (presets/preset-policy.ts) seeds exactly these five and never reads repetition_penalty, # so the server auto-recommends the same five for request parity. repetition_penalty stays a # manual-only knob (client-sent or an UNSLOTH_SAMPLING_REPETITION_PENALTY operator pin), # matching the UI where it is never auto-filled per model. _UI_RECOMMENDED_FIELDS = ("temperature", "top_p", "top_k", "min_p", "presence_penalty") def _clean_sampling_value(field: str, val: Any): """Coerce ``val`` to the field's numeric type when it is a finite, in-range number, else None. Rejects bool, non-numeric, NaN/inf, and out-of-range values so neither a bad operator env var nor a malformed model recommendation can reach llama-server. NaN matters because ``nan < lo`` and ``nan > hi`` are both False, so a plain range check would let it through. Coerce before the finiteness check: ``math.isfinite`` and ``float()`` raise ``OverflowError`` on an int too big for a C double (an oversized UNSLOTH_SAMPLING_TOP_K would otherwise 500 the request), while an in-range int is range-checked exactly and ``int()`` rejects a NaN/inf that reached an int field. """ if isinstance(val, bool) or not isinstance(val, (int, float)): return None _env, _default, lo, hi, is_int = _SAMPLING_FIELDS[field] try: val = int(val) if is_int else float(val) except (ValueError, OverflowError): # int(nan)/int(inf) and float(oversized_int) raise; treat them as unusable. return None # After coercion an int is always finite; only a float can still be NaN/inf. if isinstance(val, float) and not math.isfinite(val): return None if val < lo or val > hi: return None return val def _operator_sampling_override(field: str): """Operator-pinned value for a sampling field from UNSLOTH_SAMPLING_*, or None. An unparseable, non-finite, or out-of-range value is ignored so a bad env var can never reach llama-server; the field then falls back to the client / recommended value. """ _env, _default, _lo, _hi, is_int = _SAMPLING_FIELDS[field] raw = os.environ.get(_env) if raw is None or raw.strip() == "": return None try: val = int(raw) if is_int else float(raw) except (TypeError, ValueError): return None return _clean_sampling_value(field, val) @lru_cache(maxsize = 128) def _recommended_sampling(model_id: str) -> Dict[str, Any]: """Per-model recommended sampling, resolved through the SAME path the Studio Chat UI uses. The Chat UI seeds its sampling from the ``.inference`` block of the load/status responses, which is exactly :func:`load_inference_config` (model-specific YAML -> family defaults (inference_defaults.json) -> default.yaml). Sourcing recommendations here keeps the values the server applies to a request identical to what the UI shows for the same model. Only the fields the UI actually adopts (:data:`_UI_RECOMMENDED_FIELDS`) are recommended; each value is validated (finite + in range) before use. Cached by model id. """ if not model_id: return {} try: cfg = load_inference_config(model_id) or {} except Exception as e: logger.debug(f"Could not load recommended sampling for '{model_id}': {e}") return {} recommended: Dict[str, Any] = {} for field in _UI_RECOMMENDED_FIELDS: cleaned = _clean_sampling_value(field, cfg.get(field)) if cleaned is not None: recommended[field] = cleaned return recommended def resolve_effective_sampling( model_id: Optional[str], explicit: Dict[str, Any], *, fill_defaults: bool = True, ) -> Dict[str, Any]: """Resolve the effective sampling params for a request. ``explicit`` maps each field in :data:`SAMPLING_FIELD_NAMES` to the client-sent value, or ``None`` when the client omitted it. Precedence (highest first): an operator ``UNSLOTH_SAMPLING_*`` pin, then the client's explicit value, then the per-model recommendation, then the static schema default. When ``fill_defaults`` is False a field with no operator pin, client value, or per-model recommendation is omitted from the result instead of set to the static schema default, so a raw proxy body (``/v1/completions``) keeps llama-server's own default for that field rather than being forced onto this schema's value. """ recommended = _recommended_sampling(model_id or "") effective: Dict[str, Any] = {} for field, (_env, default, _lo, _hi, _int) in _SAMPLING_FIELDS.items(): override = _operator_sampling_override(field) if override is not None: effective[field] = override elif explicit.get(field) is not None: effective[field] = explicit[field] elif field in recommended: effective[field] = recommended[field] elif fill_defaults: effective[field] = default return effective