unsloth/studio/backend/picker/schemas.py
Unsloth 79e8c7356e studio: harden per-model settings against review findings and fuzzing
Review findings (PR #7473):
- Look up overrides under the concrete load path with its quant, not just the
  advertised repo id, so local folders and non-active HF caches are found.
- Carry bare-repo launch flags into the first per-quant save. Auto-switch
  prefers the qualified entry, so without this the flags were silently dropped
  and no UI could show or restore them. The bare id is only derived when the
  suffix looks like a quant, so a Windows drive letter is not split.
- Drop a saved gpu_ids pin that no longer resolves instead of 400ing the whole
  load. A pin outlives the machine it was made on.
- Build the displayed API base from getApiBase() on desktop; the Tauri webview
  origin is not the API server.
- Keep a partial download's isDownloaded when opening settings, so the loader
  still reports download progress.
- Only prefer the loaded quant when the loaded model is this row. Q4_K_M exists
  in most repos, so an unguarded match targeted the wrong variant.

Found by simulation:
- A lone surrogate in a chat template raised UnicodeEncodeError on the byte
  check, an unhandled 500. Now a validation error, in all three call sites.
- _bounded_int accepted bools as GPU ids, truncated fractional floats, and
  raised OverflowError on Infinity, which json.loads accepts.
- api_monitor stored a non-string model verbatim; the monitor page then threw
  on toLowerCase and rendered nothing. Coerced at the boundary and the filter
  no longer trusts network data.
- The overlay store now uses storage that cannot throw. Safari private mode and
  blocked-cookie origins make localStorage throw on access, which broke the
  opt-out toggle.
2026-07-26 07:36:12 -07:00

48 lines
1.6 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 typing import Optional
from pydantic import BaseModel, Field, field_validator
# Mirror the frontend's 64 KiB chat-template contract (per-model-config.ts) at
# the API boundary so a direct caller cannot make Jinja parse an oversized
# template. MaxBodyMiddleware only caps the whole request body, not this field.
MAX_CHAT_TEMPLATE_BYTES = 65_536
def chat_template_byte_length(value: str) -> Optional[int]:
"""UTF-8 length, or None if the string cannot be encoded at all.
JSON can carry an unpaired surrogate, as a truncated emoji paste produces.
json decodes it fine and .encode("utf-8") then raises. Callers treat None as
"reject": such a template can never render.
"""
try:
return len(value.encode("utf-8"))
except UnicodeEncodeError:
return None
class ValidateChatTemplateRequest(BaseModel):
template: str = Field(default = "")
@field_validator("template")
@classmethod
def _enforce_template_size(cls, value: str) -> str:
size = chat_template_byte_length(value)
if size is None:
raise ValueError("Chat template contains unpaired surrogate characters.")
if size > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
return value
class ValidateChatTemplateResponse(BaseModel):
valid: bool
error: Optional[str] = None
class ModelTemplateResponse(BaseModel):
model_name: str
chat_template: Optional[str] = None