Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1197 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Per-model settings management for oMLX.
This module provides dataclasses and a manager for storing and retrieving
per-model configuration settings, including sampling parameters, pinned/default
flags, and metadata.
"""
import copy
import json
import logging
import threading
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from .model_profiles import (
MODEL_SPECIFIC_PROFILE_FIELDS,
filter_profile_fields,
filter_universal_fields,
slugify_profile_api_name,
validate_profile_name,
utcnow,
)
logger = logging.getLogger(__name__)
# Current settings file format version
SETTINGS_VERSION = 1
PROFILES_VERSION = 1
TEMPLATES_VERSION = 1
@dataclass
class ModelSettings:
"""Per-model configuration settings.
Attributes:
max_context_window: Maximum prompt token count before rejection (None = use global default).
max_tokens: Maximum number of tokens to generate (None = use global default).
temperature: Sampling temperature (None = use global default).
top_p: Nucleus sampling probability (None = use global default).
top_k: Top-k sampling parameter (None = use global default).
min_p: Minimum probability threshold (None = use global default).
repetition_penalty: Repetition penalty (None = use default 1.0, i.e. disabled).
presence_penalty: Presence penalty (None = use global default).
force_sampling: Force sampling even with temperature=0.
max_tool_result_tokens: Maximum tokens in tool result (None = use global default).
chat_template_kwargs: Extra chat template keyword arguments.
forced_ct_kwargs: Keys in chat_template_kwargs that cannot be overridden.
ttl_seconds: Auto-unload after idle seconds (None = no TTL).
model_type_override: "llm", "vlm", "embedding", "reranker", or None (auto-detect).
model_alias: API-visible alternative to the directory name.
index_cache_freq: IndexCache: every Nth layer keeps indexer (DeepSeek DSA
only; GLM-5.2 uses its native checkpoint schedule).
enable_thinking: Explicit toggle for thinking/reasoning mode (None = auto).
thinking_budget_enabled: Whether a thinking token budget is active.
thinking_budget_tokens: Max tokens for thinking/reasoning.
reasoning_parser: xgrammar builtin name: "qwen", "harmony", "llama", etc.
guided_grammar_enabled: Whether a default guided grammar is active.
guided_grammar: Default EBNF grammar for constrained decoding.
turboquant_kv_enabled: Enable TurboQuant KV cache compression.
turboquant_kv_bits: TurboQuant bit depth (2/2.5/3/3.5/4/6/8).
turboquant_skip_last: Skip last KVCache layer to prevent corruption.
specprefill_enabled: Enable SpecPrefill (experimental sparse prefill for MoE).
specprefill_draft_model: Path to draft model for SpecPrefill.
specprefill_keep_pct: Keep rate for SpecPrefill (0.10.5).
specprefill_threshold: Min tokens to trigger SpecPrefill.
dflash_enabled: Enable DFlash speculative decoding.
dflash_draft_model: Path/repo for DFlash draft checkpoint.
dflash_draft_quant_enabled: Enable draft model quantization.
dflash_draft_quant_weight_bits: Quantization weight bits (2, 4, 8).
dflash_draft_quant_activation_bits: Quantization activation bits (16, 32).
dflash_draft_quant_group_size: Quantization group size (32, 64, 128).
dflash_max_ctx: Token threshold to fall back to BatchedEngine (None = unlimited).
dflash_in_memory_cache: Enable DFlash L1 (RAM) prefix cache.
dflash_in_memory_cache_max_entries: L1 cache max entries (default 4, matches dflash balanced profile).
dflash_in_memory_cache_max_bytes: L1 cache byte budget.
dflash_ssd_cache: Enable DFlash L2 (SSD) prefix cache spill (uses omlx SSD cache dir).
dflash_ssd_cache_max_bytes: L2 (SSD) disk budget; dflash evicts oldest entries when exceeded.
dflash_draft_window_size: Draft model sliding-attention window (None = dflash default 1024).
Helps stabilise acceptance rate on long-context prompts.
dflash_draft_sink_size: Attention-sink tokens always kept regardless of window
(None = dflash default 64).
dflash_verify_mode: Verifier algorithm — "dflash", "adaptive", "ddtree", or "off"
(None = dflash default "adaptive"). "adaptive" can shrink block size when
acceptance drops.
mtp_enabled: Enable native multi-token prediction (mlx-lm PR 990 / PR 15 monkey-patch).
When True, BatchGenerator uses MTP draft+verify for singleton decode and
for multi-row decode batches whose cache positions are aligned. Unaligned
continuous batches fall back to standard decoding automatically. Compatible
model_types: qwen3_5*, qwen3_6*, deepseek_v4*. Mutually exclusive with
dflash_enabled.
vlm_mtp_enabled: Enable VLM MTP speculative decoding via an external assistant
drafter (mlx-vlm 191d7c8+). Target = Gemma4 VLM body, drafter must be a
"gemma4_assistant" model.
vlm_mtp_draft_model: Path/repo of the assistant drafter (e.g. "gemma-4-26B-A4B-it-assistant").
vlm_mtp_draft_block_size: Tokens drafted per round (None = mlx-vlm default).
is_pinned: Keep model loaded in memory.
is_default: Use this model when no model is specified.
display_name: Human-readable name for UI display.
description: Optional description of the model.
active_profile_name: Name of the currently-applied profile (None = no profile).
"""
# Sampling parameters (None means use global default)
max_context_window: Optional[int] = None
max_tokens: Optional[int] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
top_k: Optional[int] = None
repetition_penalty: Optional[float] = None
min_p: Optional[float] = None
presence_penalty: Optional[float] = None
force_sampling: bool = False
max_tool_result_tokens: Optional[int] = None
chat_template_kwargs: Optional[Dict[str, Any]] = None
forced_ct_kwargs: Optional[list[str]] = (
None # Keys that cannot be overridden by API requests
)
ttl_seconds: Optional[int] = None # Auto-unload after idle seconds (None = no TTL)
model_type_override: Optional[str] = (
None # "llm", "vlm", "embedding", "reranker", or None (auto-detect)
)
model_alias: Optional[str] = (
None # API-visible name (alternative to directory name)
)
index_cache_freq: Optional[int] = (
None # IndexCache: every Nth layer keeps indexer (DeepSeek DSA only)
)
enable_thinking: Optional[bool] = (
None # Explicit toggle for thinking/reasoning mode (None = auto)
)
preserve_thinking: Optional[bool] = (
None # Keep <think> blocks in historical turns (None = auto, True when template supports it)
)
thinking_budget_enabled: bool = False
thinking_budget_tokens: Optional[int] = None
reasoning_parser: Optional[str] = (
None # xgrammar builtin name: "qwen", "harmony", "llama", etc.
)
guided_grammar_enabled: bool = False
guided_grammar: Optional[str] = None
# TurboQuant KV cache (mlx-vlm backend)
turboquant_kv_enabled: bool = False
turboquant_kv_bits: float = 4 # 2, 2.5, 3, 3.5, 4, 6, 8
turboquant_skip_last: bool = (
True # Skip last KVCache layer (prevents corruption on sensitive models)
)
# SpecPrefill (experimental: attention-based sparse prefill for MoE models)
specprefill_enabled: bool = False
specprefill_draft_model: Optional[str] = (
None # Path to draft model (must share tokenizer)
)
specprefill_keep_pct: Optional[float] = None # Keep rate (0.1-0.5, default 0.2)
specprefill_threshold: Optional[int] = None # Min tokens to trigger (default 8192)
# DFlash (block diffusion speculative decoding)
dflash_enabled: bool = False
dflash_draft_model: Optional[str] = None # Path/repo for DFlash draft checkpoint
dflash_draft_quant_enabled: Optional[bool] = None
dflash_draft_quant_weight_bits: Optional[int] = None # 2, 4, 8
dflash_draft_quant_activation_bits: Optional[int] = None # 16, 32
dflash_draft_quant_group_size: Optional[int] = None # 32, 64, 128
dflash_max_ctx: Optional[int] = (
None # None = unlimited; trigger BatchedEngine fallback when prompt_len >= this
)
# DFlash prefix cache (private to dflash; separate from omlx tiered cache because
# snapshots include draft model GDN state and target hidden chunks omlx never tracks)
dflash_in_memory_cache: bool = True
dflash_in_memory_cache_max_entries: int = (
4 # Matches dflash balanced profile default
)
dflash_in_memory_cache_max_bytes: int = (
8 * 1024 * 1024 * 1024
) # 8 GiB (balanced profile default)
dflash_ssd_cache: bool = (
False # Requires in-memory cache and an omlx paged SSD cache dir
)
dflash_ssd_cache_max_bytes: int = 20 * 1024 * 1024 * 1024 # 20 GiB L2 disk budget
# DFlash runtime tuning knobs. None = let dflash-mlx pick its own DEFAULT_RUNTIME_CONFIG
# value (currently window=1024, sink=64, verify_mode="adaptive"). Surfaced for long-context
# agentic workloads where acceptance drops on the default sliding window.
dflash_draft_window_size: Optional[int] = None
dflash_draft_sink_size: Optional[int] = None
dflash_verify_mode: Optional[str] = None # "dflash" | "adaptive" | "ddtree" | "off"
# Native MTP (mlx-lm PR 990 / PR 15 monkey-patch). When enabled, BatchGenerator
# uses MTP draft+verify for singleton decode and aligned multi-row decode batches.
# Compatible model_types: qwen3_5*, qwen3_6*, deepseek_v4*. Mutually exclusive
# with dflash.
mtp_enabled: bool = False
# Maximum chained MTP draft tokens per verify cycle (speculative depth).
# None = default (3). Effective for Qwen3.5/3.6 native MTP only;
# DeepSeek-V4 native MTP always runs depth 1. An adaptive controller
# picks 1..max per sequence from rolling acceptance/latency estimates;
# set to 1 for a fixed depth-1 cycle.
mtp_num_draft_tokens: Optional[int] = None
# VLM MTP speculative decoding via external MTP drafter (mlx-vlm f96138e+).
# Supported drafter types: gemma4_assistant (for Gemma 4 VLMs), qwen3_5_mtp
# (for Qwen 3.5/3.6). Both resolve to draft_kind="mtp" in mlx-vlm.
# Mutually exclusive with all other speculative paths because the wrapper
# bypasses mlx-lm BatchGenerator at decode time.
vlm_mtp_enabled: bool = False
vlm_mtp_draft_model: Optional[str] = (
None # Path / model id of the assistant drafter
)
vlm_mtp_draft_block_size: Optional[int] = (
None # Tokens per draft round (None = mlx-vlm default)
)
# Model management flags
is_pinned: bool = False
is_default: bool = False # Only one model can be default
is_hidden: bool = False # Hidden from /v1/models (still shown, badged, in admin)
is_favorite: bool = False # Listed first in /v1/models and admin lists
# Security: opt-in per model. When True, mlx-lm/mlx-vlm/mlx-embeddings/reranker
# loaders are allowed to execute custom Python from the model repository
# (modeling_*.py, tokenization_*.py). Off by default — see issue #926.
trust_remote_code: bool = False
# Metadata
display_name: Optional[str] = None
description: Optional[str] = None
active_profile_name: Optional[str] = None # Name of the currently-applied profile
def __post_init__(self) -> None:
# Native MTP is mutually exclusive with DFlash (also speculative).
# Reject the combo at construction time so the conflict surfaces in
# the admin UI / API rather than at model load. TurboQuant KV is
# compatible: its attention patch routes MTP's decode-shaped
# multi-row verify through the quantized decode kernels.
if self.mtp_enabled and self.dflash_enabled:
raise ValueError(
"mtp_enabled and dflash_enabled cannot both be True; choose one "
"speculative-decoding path per model"
)
# vlm_mtp wraps mlx-vlm's MTP loop and bypasses mlx-lm BatchGenerator
# at decode time, so it cannot coexist with any other speculative path
# or with TurboQuant (which mutates the same cache objects).
if self.vlm_mtp_enabled:
conflicts = [
("dflash_enabled", self.dflash_enabled),
("specprefill_enabled", self.specprefill_enabled),
("mtp_enabled", self.mtp_enabled),
("turboquant_kv_enabled", self.turboquant_kv_enabled),
]
for name, value in conflicts:
if value:
raise ValueError(
f"vlm_mtp_enabled and {name} cannot both be True; "
"choose one speculative path per model"
)
def to_dict(self) -> dict:
"""Convert to dictionary, excluding None values.
Returns:
Dictionary representation with None values filtered out.
"""
result = {}
for f in fields(self):
value = getattr(self, f.name)
if value is not None:
result[f.name] = value
return result
@classmethod
def from_dict(cls, data: dict) -> "ModelSettings":
"""Create ModelSettings from a dictionary.
Args:
data: Dictionary containing settings values.
Returns:
New ModelSettings instance with values from dict.
"""
# Get valid field names
valid_fields = {f.name for f in fields(cls)}
# Filter to only valid keys
filtered_data = {k: v for k, v in data.items() if k in valid_fields}
return cls(**filtered_data)
class ModelSettingsManager:
"""Manager for per-model settings with file persistence.
Handles loading, saving, and accessing model settings from a JSON file.
Thread-safe for concurrent access.
Attributes:
base_path: Base directory for settings storage.
settings_file: Path to the settings JSON file.
"""
def __init__(self, base_path: Path):
"""Initialize the settings manager.
Args:
base_path: Base directory for settings storage.
"""
self.base_path = Path(base_path)
self.settings_file = self.base_path / "model_settings.json"
self.profiles_file = self.base_path / "model_profiles.json"
self.templates_file = self.base_path / "global_templates.json"
self._lock = threading.Lock()
self._settings: Dict[str, ModelSettings] = {}
self._profiles: Dict[str, Dict[str, Dict[str, Any]]] = {}
self._templates: Dict[str, Dict[str, Any]] = {}
# Ensure base directory exists
self.base_path.mkdir(parents=True, exist_ok=True)
# Load existing settings
self._load()
self._load_profiles()
self._load_templates()
def _load(self) -> None:
"""Load settings from the JSON file.
If the file doesn't exist or is invalid, starts with empty settings.
"""
if not self.settings_file.exists():
logger.debug(f"Settings file not found: {self.settings_file}")
self._settings = {}
return
try:
with open(self.settings_file, "r", encoding="utf-8") as f:
data = json.load(f)
# Check version
version = data.get("version", 1)
if version != SETTINGS_VERSION:
logger.warning(
f"Settings file version {version} differs from current {SETTINGS_VERSION}"
)
# Load model settings
models_data = data.get("models", {})
self._settings = {}
for model_id, model_data in models_data.items():
try:
self._settings[model_id] = ModelSettings.from_dict(model_data)
except Exception as e:
logger.warning(
f"Failed to load settings for model '{model_id}': {e}"
)
logger.info(f"Loaded settings for {len(self._settings)} models")
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in settings file: {e}")
self._settings = {}
except Exception as e:
logger.error(f"Failed to load settings file: {e}")
self._settings = {}
def _save(self) -> None:
"""Save settings to the JSON file.
Must be called while holding the lock.
"""
data = {
"version": SETTINGS_VERSION,
"models": {
model_id: settings.to_dict()
for model_id, settings in self._settings.items()
},
}
try:
# Write to temp file first, then rename for atomicity
temp_file = self.settings_file.with_suffix(".tmp")
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
temp_file.replace(self.settings_file)
logger.debug(f"Saved settings for {len(self._settings)} models")
except Exception as e:
logger.error(f"Failed to save settings file: {e}")
raise
def get_settings(self, model_id: str) -> ModelSettings:
"""Get settings for a specific model.
Args:
model_id: The model identifier.
Returns:
ModelSettings for the model, or default settings if not found.
"""
with self._lock:
if model_id in self._settings:
# Return a copy to prevent external modification
settings = self._settings[model_id]
return ModelSettings.from_dict(settings.to_dict())
return ModelSettings()
def get_settings_for_request(
self,
model_id: str,
resolved_model_id: Optional[str] = None,
) -> ModelSettings:
"""Get settings for an API-requested model name.
Exposed profile model IDs return the base model's settings merged
with the profile's universal (request-time) overrides. Engine-
construction fields are handled separately by
get_exposed_profile_runtime_settings_for_request(), which may trigger
a transient engine variant reload without mutating persisted settings.
Any other name falls back to the settings of the already-resolved
physical model.
"""
with self._lock:
candidates = [model_id]
if "/" in model_id:
candidates.append(model_id.split("/", 1)[1])
for candidate in candidates:
profile_match = self._find_exposed_profile_locked(candidate)
if profile_match is not None:
base_model_id, profile = profile_match
return self._settings_with_profile_locked(base_model_id, profile)
return self.get_settings(resolved_model_id or model_id)
def set_settings(self, model_id: str, settings: ModelSettings) -> None:
"""Set settings for a specific model.
If the new settings have is_default=True, clears is_default from all
other models to maintain the exclusive default constraint.
Args:
model_id: The model identifier.
settings: The settings to apply.
"""
with self._lock:
# Handle exclusive default constraint
if settings.is_default:
for mid, s in self._settings.items():
if mid != model_id and s.is_default:
s.is_default = False
logger.info(
f"Cleared is_default from model '{mid}' "
f"(new default: '{model_id}')"
)
# Store a copy of the settings
self._settings[model_id] = ModelSettings.from_dict(settings.to_dict())
logger.info(f"Updated settings for model '{model_id}'")
self._save()
def delete_settings(self, model_id: str) -> bool:
"""Remove all persisted state for a model (settings + profiles).
Called when a model is deleted so its alias and other settings are
released and can be reused by another model.
Args:
model_id: The model identifier.
Returns:
True if any state was removed, False if nothing was stored.
"""
with self._lock:
removed = False
if model_id in self._settings:
del self._settings[model_id]
self._save()
removed = True
if model_id in self._profiles:
del self._profiles[model_id]
self._save_profiles()
removed = True
if removed:
logger.info(f"Deleted settings for model '{model_id}'")
return removed
def get_default_model_id(self) -> Optional[str]:
"""Get the ID of the default model.
Returns:
The model ID marked as default, or None if no default is set.
"""
with self._lock:
for model_id, settings in self._settings.items():
if settings.is_default:
return model_id
return None
def get_pinned_model_ids(self) -> list[str]:
"""Get list of all pinned model IDs.
Returns:
List of model IDs that are marked as pinned.
"""
with self._lock:
return [
model_id
for model_id, settings in self._settings.items()
if settings.is_pinned
]
def get_all_settings(self) -> Dict[str, ModelSettings]:
"""Get a copy of all model settings.
Returns:
Dictionary mapping model IDs to their settings (deep copy).
"""
with self._lock:
return {
model_id: ModelSettings.from_dict(settings.to_dict())
for model_id, settings in self._settings.items()
}
# ==================== Profiles ====================
def _load_profiles(self) -> None:
if not self.profiles_file.exists():
self._profiles = {}
return
try:
with open(self.profiles_file, "r", encoding="utf-8") as f:
data = json.load(f)
version = data.get("version", 1)
if version != PROFILES_VERSION:
logger.warning(
f"Profiles file version {version} differs from current {PROFILES_VERSION}"
)
self._profiles = data.get("profiles", {}) or {}
# Migration: strip ttl_seconds from existing profile settings and
# add api_name for exposed-model IDs without changing internal keys.
changed = False
for model_id, profiles in self._profiles.items():
model_changed = False
used_api_names: set[str] = set()
for name, profile in profiles.items():
settings = profile.get("settings")
if settings and "ttl_seconds" in settings:
del settings["ttl_seconds"]
changed = True
model_changed = True
current_api_name = profile.get("api_name")
if current_api_name:
try:
validate_profile_name(current_api_name)
base_api_name = current_api_name
except Exception:
base_api_name = slugify_profile_api_name(
profile.get("display_name") or name,
fallback="profile",
)
else:
base_api_name = slugify_profile_api_name(
profile.get("display_name") or name,
fallback="profile",
)
api_name = self._dedupe_profile_api_name(
base_api_name,
used_api_names,
)
if current_api_name != api_name:
profile["api_name"] = api_name
changed = True
model_changed = True
if model_changed:
logger.info(f"Migrated profile api names for model '{model_id}'")
if changed:
self._save_profiles()
except Exception as e:
logger.error(f"Failed to load profiles file: {e}")
self._profiles = {}
def _save_profiles(self) -> None:
"""Write profiles to disk atomically (temp file + rename)."""
data = {"version": PROFILES_VERSION, "profiles": self._profiles}
temp_file = self.profiles_file.with_suffix(".tmp")
try:
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
temp_file.replace(self.profiles_file)
except Exception as e:
logger.error(f"Failed to save profiles file: {e}")
if temp_file.exists():
temp_file.unlink(missing_ok=True)
raise
@staticmethod
def _dedupe_profile_api_name(base: str, used: set[str]) -> str:
validate_profile_name(base)
candidate = base
index = 2
while candidate in used:
suffix = f"-{index}"
root = base[: 32 - len(suffix)].rstrip("-_") or "profile"
candidate = f"{root}{suffix}"
index += 1
used.add(candidate)
return candidate
@staticmethod
def _profile_api_name(profile: Dict[str, Any]) -> str:
api_name = profile.get("api_name") or profile["name"]
validate_profile_name(api_name)
return api_name
def _allocate_profile_api_name_locked(
self,
profiles: Dict[str, Dict[str, Any]],
value: str | None,
*,
display_name: str | None,
internal_name: str,
exclude_name: str | None = None,
) -> str:
if value:
validate_profile_name(value)
base = value
else:
base = slugify_profile_api_name(
display_name or internal_name,
fallback="profile",
)
used = {
self._profile_api_name(profile)
for name, profile in profiles.items()
if name != exclude_name
}
return self._dedupe_profile_api_name(base, used)
def _profile_model_id(self, model_id: str, api_name: str) -> str:
return f"{model_id}:{api_name}"
def _display_profile_model_id_locked(
self, model_id: str, profile: Dict[str, Any]
) -> str:
"""Advertised form of an exposed profile's model ID.
Uses the base model's alias when set, mirroring how /v1/models
lists the base model itself. The directory-name form remains
accepted for requests, exactly like the base model's directory
name.
"""
base = self._settings.get(model_id)
display_base = base.model_alias if base and base.model_alias else model_id
return self._profile_model_id(display_base, self._profile_api_name(profile))
def _find_exposed_profile_locked(
self, model_id: str
) -> Optional[tuple[str, Dict[str, Any]]]:
for base_model_id, profiles in self._profiles.items():
base = self._settings.get(base_model_id)
alias = base.model_alias if base else None
for profile in profiles.values():
if not profile.get("expose_as_model"):
continue
api_name = self._profile_api_name(profile)
if model_id == self._profile_model_id(base_model_id, api_name):
return base_model_id, profile
if alias and model_id == self._profile_model_id(alias, api_name):
return base_model_id, profile
return None
def _settings_with_profile_locked(
self, model_id: str, profile: Dict[str, Any]
) -> ModelSettings:
base = self._settings.get(model_id)
merged = base.to_dict() if base is not None else {}
# Request-time settings still use only universal fields. Engine-
# construction fields are handled separately by
# get_exposed_profile_runtime_settings_for_request(), which can
# trigger an engine variant reload without persisting base settings.
merged.update(filter_universal_fields(profile.get("settings", {}) or {}))
return ModelSettings.from_dict(merged)
def _runtime_settings_with_profile_locked(
self, model_id: str, profile: Dict[str, Any]
) -> ModelSettings:
base = self._settings.get(model_id)
merged = base.to_dict() if base is not None else {}
merged.update(filter_profile_fields(profile.get("settings", {}) or {}))
return ModelSettings.from_dict(merged)
def get_exposed_profile_source_model_id(self, model_id: str) -> Optional[str]:
"""Return the base model for an exposed profile model ID, if any."""
with self._lock:
candidates = [model_id]
if "/" in model_id:
candidates.append(model_id.split("/", 1)[1])
for candidate in candidates:
match = self._find_exposed_profile_locked(candidate)
if match is not None:
return match[0]
return None
def get_exposed_profile_runtime_settings_for_request(
self,
model_id: str,
) -> Optional[tuple[str, ModelSettings]]:
"""Return full runtime settings for an exposed profile request.
Unlike ``get_settings_for_request()``, this includes model-specific
engine-construction fields. It is used only for transient engine
loading and never mutates the base model's persisted settings.
"""
with self._lock:
candidates = [model_id]
if "/" in model_id:
candidates.append(model_id.split("/", 1)[1])
for candidate in candidates:
match = self._find_exposed_profile_locked(candidate)
if match is not None:
base_model_id, profile = match
return (
base_model_id,
self._runtime_settings_with_profile_locked(
base_model_id, profile
),
)
return None
def get_exposed_profile_model_ids(
self,
*,
exclude_model_id: str | None = None,
exclude_profile_name: str | None = None,
) -> set[str]:
"""Return every request ID accepted by exposed profiles."""
with self._lock:
model_ids: set[str] = set()
for base_model_id, profiles in self._profiles.items():
base = self._settings.get(base_model_id)
alias = base.model_alias if base else None
for profile in profiles.values():
if not profile.get("expose_as_model"):
continue
if (
exclude_model_id == base_model_id
and exclude_profile_name == profile["name"]
):
continue
api_name = self._profile_api_name(profile)
model_ids.add(self._profile_model_id(base_model_id, api_name))
if alias:
model_ids.add(self._profile_model_id(alias, api_name))
return model_ids
def _profile_request_ids_locked(
self,
model_id: str,
profile: Dict[str, Any],
) -> set[str]:
base = self._settings.get(model_id)
base_ids = {model_id}
if base and base.model_alias:
base_ids.add(base.model_alias)
api_name = self._profile_api_name(profile)
return {self._profile_model_id(base_id, api_name) for base_id in base_ids}
def _validate_exposed_profile_ids_available_locked(
self,
model_id: str,
profile: Dict[str, Any],
*,
exclude_profile_name: str | None = None,
reserved_model_ids: set[str] | None = None,
) -> None:
if not profile.get("expose_as_model"):
return
candidate_ids = self._profile_request_ids_locked(model_id, profile)
if reserved_model_ids:
for candidate_id in candidate_ids:
if candidate_id != model_id and candidate_id in reserved_model_ids:
raise ValueError(
f"Exposed profile model ID '{candidate_id}' conflicts "
"with a model directory name"
)
for mid, settings in self._settings.items():
if settings.model_alias and settings.model_alias in candidate_ids:
raise ValueError(
f"Exposed profile model ID '{settings.model_alias}' "
f"conflicts with model alias for '{mid}'"
)
existing_ids: set[str] = set()
for base_model_id, profiles in self._profiles.items():
for other in profiles.values():
if not other.get("expose_as_model"):
continue
if base_model_id == model_id and other["name"] == exclude_profile_name:
continue
existing_ids.update(
self._profile_request_ids_locked(base_model_id, other)
)
conflict = candidate_ids & existing_ids
if conflict:
conflict_id = sorted(conflict)[0]
raise ValueError(f"Exposed profile model ID '{conflict_id}' already exists")
def list_exposed_profile_models(self) -> list[dict]:
"""Return profile records promoted to independently visible model IDs."""
with self._lock:
exposed = []
for base_model_id, profiles in self._profiles.items():
for profile in profiles.values():
if not profile.get("expose_as_model"):
continue
item = dict(profile)
item["model_id"] = self._display_profile_model_id_locked(
base_model_id, profile
)
item["source_model_id"] = base_model_id
item["settings"] = self._settings_with_profile_locked(
base_model_id, item
).to_dict()
exposed.append(item)
return exposed
@staticmethod
def _has_engine_fields(profile: Dict[str, Any]) -> bool:
"""True when the profile overrides any engine-construction field.
Those fields are ignored by the request-time sampling overlay but are
used for transient engine variant reloads on exposed-profile requests.
UIs use this flag to indicate that a profile changes load-time state.
"""
settings = profile.get("settings", {}) or {}
return any(
k in MODEL_SPECIFIC_PROFILE_FIELDS and v is not None
for k, v in settings.items()
)
def list_profiles(self, model_id: str) -> list[dict]:
"""Return all profiles for ``model_id`` as serializable dicts."""
with self._lock:
per_model = self._profiles.get(model_id, {})
return [
{
**p,
"model_id": self._display_profile_model_id_locked(model_id, p),
"has_engine_fields": self._has_engine_fields(p),
}
for p in per_model.values()
]
def get_profile(self, model_id: str, name: str) -> Optional[dict]:
with self._lock:
return dict(self._profiles.get(model_id, {}).get(name, {})) or None
def save_profile(
self,
model_id: str,
name: str,
display_name: str,
description: Optional[str],
settings: Dict[str, Any],
source_template: Optional[str] = None,
expose_as_model: bool = False,
api_name: Optional[str] = None,
reserved_model_ids: Optional[set[str]] = None,
) -> dict:
"""Create a new profile. Raises if name is invalid or already exists."""
validate_profile_name(name)
filtered = filter_profile_fields(settings or {})
with self._lock:
per_model = self._profiles.setdefault(model_id, {})
if name in per_model:
raise ValueError(
f"Profile '{name}' already exists for model '{model_id}'"
)
now = utcnow().isoformat()
profile_api_name = self._allocate_profile_api_name_locked(
per_model,
api_name,
display_name=display_name,
internal_name=name,
)
profile_record = {
"name": name,
"display_name": display_name or name,
"api_name": profile_api_name,
"description": description,
"created_at": now,
"updated_at": now,
"settings": filtered,
"source_template": source_template,
"expose_as_model": bool(expose_as_model),
}
self._validate_exposed_profile_ids_available_locked(
model_id,
profile_record,
reserved_model_ids=reserved_model_ids,
)
per_model[name] = profile_record
self._save_profiles()
return dict(per_model[name])
def update_profile(
self,
model_id: str,
name: str,
*,
new_name: Optional[str] = None,
display_name: Optional[str] = None,
description: Optional[str] = None,
settings: Optional[Dict[str, Any]] = None,
source_template: Optional[str] = None,
expose_as_model: Optional[bool] = None,
api_name: Optional[str] = None,
reserved_model_ids: Optional[set[str]] = None,
) -> Optional[dict]:
"""Update a profile's metadata/settings. Returns updated dict or None if not found."""
with self._lock:
per_model = self._profiles.get(model_id, {})
if name not in per_model:
return None
profile = dict(per_model[name])
target_name = name
rename_mode = False
if new_name is not None and new_name != name:
validate_profile_name(new_name)
if new_name in per_model:
raise ValueError(
f"Profile '{new_name}' already exists for model '{model_id}'"
)
target_name = new_name
profile["name"] = new_name
rename_mode = True
if display_name is not None:
profile["display_name"] = display_name
if api_name is not None:
profile["api_name"] = self._allocate_profile_api_name_locked(
per_model,
api_name,
display_name=profile.get("display_name"),
internal_name=target_name,
exclude_name=name,
)
if description is not None:
profile["description"] = description
if settings is not None:
profile["settings"] = filter_profile_fields(settings)
if source_template is not None:
profile["source_template"] = source_template or None
if expose_as_model is not None:
profile["expose_as_model"] = bool(expose_as_model)
profile["updated_at"] = utcnow().isoformat()
self._validate_exposed_profile_ids_available_locked(
model_id,
profile,
exclude_profile_name=name,
reserved_model_ids=reserved_model_ids,
)
# Snapshot for rollback on write failure
profiles_snapshot = copy.deepcopy(self._profiles)
settings_snapshot = copy.deepcopy(self._settings)
# Also update ModelSettings.active_profile_name if renamed and it was active
old_active = None
if rename_mode:
old_active = self._settings.get(model_id)
if old_active is not None and old_active.active_profile_name == name:
old_active.active_profile_name = target_name
del per_model[name]
per_model[target_name] = profile
# Write profiles first; if this throws, rollback everything
try:
self._save_profiles()
if rename_mode and old_active is not None:
self._save()
except Exception:
self._profiles = profiles_snapshot
self._settings = settings_snapshot
raise
return dict(profile)
def delete_profile(self, model_id: str, name: str) -> bool:
with self._lock:
per_model = self._profiles.get(model_id, {})
if name not in per_model:
return False
profiles_snapshot = copy.deepcopy(self._profiles)
settings_snapshot = copy.deepcopy(self._settings)
del per_model[name]
if not per_model and model_id in self._profiles:
del self._profiles[model_id]
# Clear active_profile_name if it referenced this profile
old_active = self._settings.get(model_id)
if old_active is not None and old_active.active_profile_name == name:
old_active.active_profile_name = None
try:
self._save_profiles()
if old_active is not None and old_active.active_profile_name is None:
self._save()
except Exception:
self._profiles = profiles_snapshot
self._settings = settings_snapshot
raise
return True
def apply_profile(
self,
model_id: str,
name: str,
settings_sanitizer: Optional[Callable[[dict[str, Any]], None]] = None,
) -> Optional[ModelSettings]:
"""Merge profile settings into the model's live settings and persist."""
with self._lock:
per_model = self._profiles.get(model_id, {})
if name not in per_model:
return None
profile_settings = per_model[name].get("settings", {}) or {}
settings_snapshot = copy.deepcopy(self._settings)
current = self._settings.get(model_id)
if current is None:
current = ModelSettings()
merged = current.to_dict()
for k, v in profile_settings.items():
merged[k] = v
merged["active_profile_name"] = name
if settings_sanitizer is not None:
settings_sanitizer(merged)
new_settings = ModelSettings.from_dict(merged)
self._settings[model_id] = new_settings
try:
self._save()
except Exception:
self._settings = settings_snapshot
raise
return ModelSettings.from_dict(new_settings.to_dict())
# ==================== Templates ====================
def _load_templates(self) -> None:
# Built-in defaults ship inside the package (omlx/default_global_templates.json)
# and are merged in at read time — they are NEVER copied to disk and never
# appear in `self._templates`. The user file under <base_path> holds
# ONLY user-created templates; a missing/empty file is the legitimate
# initial state.
if not self.templates_file.exists():
self._templates = {}
return
try:
with open(self.templates_file, "r", encoding="utf-8") as f:
data = json.load(f)
version = data.get("version", 1)
if version != TEMPLATES_VERSION:
logger.warning(
f"Templates file version {version} differs from current {TEMPLATES_VERSION}"
)
self._templates = data.get("templates", {}) or {}
# Migration: strip ttl_seconds from existing template settings
for name, template in self._templates.items():
settings = template.get("settings")
if settings and "ttl_seconds" in settings:
del settings["ttl_seconds"]
except Exception as e:
logger.error(f"Failed to load templates file: {e}")
self._templates = {}
def _save_templates(self) -> None:
"""Must be called while holding the lock."""
data = {"version": TEMPLATES_VERSION, "templates": self._templates}
try:
temp_file = self.templates_file.with_suffix(".tmp")
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
temp_file.replace(self.templates_file)
except Exception as e:
logger.error(f"Failed to save templates file: {e}")
raise
def list_templates(self) -> list[dict]:
# Shipped JSON seeds were retired in favor of the client-side preset
# bundle (`omlx/admin/static/omlx_preset.json`); every entry on this
# surface is user-created. Callers that distinguish presets from
# user templates do so via the preset bundle, not an `is_builtin`
# flag on this response.
with self._lock:
return [dict(t) for t in self._templates.values()]
def get_template(self, name: str) -> Optional[dict]:
with self._lock:
u = self._templates.get(name)
return dict(u) if u is not None else None
def save_template(
self,
name: str,
display_name: str,
description: Optional[str],
settings: Dict[str, Any],
) -> dict:
validate_profile_name(name)
filtered = filter_universal_fields(settings or {})
with self._lock:
if name in self._templates:
raise ValueError(f"Template '{name}' already exists")
now = utcnow().isoformat()
self._templates[name] = {
"name": name,
"display_name": display_name or name,
"description": description,
"created_at": now,
"updated_at": now,
"settings": filtered,
}
self._save_templates()
return dict(self._templates[name])
def upsert_template(
self,
name: str,
display_name: str,
description: Optional[str],
settings: Dict[str, Any],
) -> dict:
"""Create or replace a template with the given settings."""
validate_profile_name(name)
filtered = filter_universal_fields(settings or {})
with self._lock:
now = utcnow().isoformat()
existing = self._templates.get(name)
created_at = existing["created_at"] if existing else now
self._templates[name] = {
"name": name,
"display_name": display_name or name,
"description": description,
"created_at": created_at,
"updated_at": now,
"settings": filtered,
}
self._save_templates()
return dict(self._templates[name])
def update_template(
self,
name: str,
*,
new_name: Optional[str] = None,
display_name: Optional[str] = None,
description: Optional[str] = None,
settings: Optional[Dict[str, Any]] = None,
) -> Optional[dict]:
with self._lock:
if name not in self._templates:
return None
template = dict(self._templates[name])
target = name
if new_name is not None and new_name != name:
validate_profile_name(new_name)
if new_name in self._templates:
raise ValueError(f"Template '{new_name}' already exists")
target = new_name
template["name"] = new_name
if display_name is not None:
template["display_name"] = display_name
if description is not None:
template["description"] = description
if settings is not None:
template["settings"] = filter_universal_fields(settings)
template["updated_at"] = utcnow().isoformat()
if target != name:
del self._templates[name]
self._templates[target] = template
self._save_templates()
return dict(template)
def delete_template(self, name: str) -> bool:
with self._lock:
if name not in self._templates:
return False
del self._templates[name]
self._save_templates()
return True