From ca7e0cb52768fc1bcf26b2a368ea03d39e84fa87 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 17 Jul 2026 13:38:07 +0000 Subject: [PATCH 1/7] Send the HF token as a header, not a URL query param The model config, vision-check, and embedding-check requests appended the Hugging Face token as ?hf_token=..., which leaks it into server access logs, proxy logs, and browser history for gated/private models. Send it via the existing X-Unsloth-HF-Token header (hubTokenHeader) instead, and read it on the backend through the standard get_hf_token dependency, matching the picker's template routes. Also drop two catch-block comments that just restated the code. --- studio/backend/routes/models.py | 6 +++--- .../src/features/training/api/models-api.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index b8526c75e7..92dac00347 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -1796,7 +1796,7 @@ def _get_model_size_bytes(model_name: str, hf_token: Optional[str] = None) -> Op @router.get("/config/{model_name:path}") async def get_model_config( model_name: str, - hf_token: Optional[str] = Query(None), + hf_token: Optional[str] = Depends(get_hf_token), current_subject: str = Depends(get_current_subject), ): """Get configuration for a specific model (wraps load_model_defaults).""" @@ -2517,7 +2517,7 @@ async def get_lora_base_model(lora_path: str, current_subject: str = Depends(get @router.get("/check-vision/{model_name:path}", response_model = VisionCheckResponse) async def check_vision_model( model_name: str, - hf_token: Optional[str] = Query(None), + hf_token: Optional[str] = Depends(get_hf_token), current_subject: str = Depends(get_current_subject), ): """ @@ -2549,7 +2549,7 @@ async def check_vision_model( @router.get("/check-embedding/{model_name:path}", response_model = EmbeddingCheckResponse) async def check_embedding_model( model_name: str, - hf_token: Optional[str] = Query(None), + hf_token: Optional[str] = Depends(get_hf_token), current_subject: str = Depends(get_current_subject), ): """ diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts index e5fd8b043d..2ccc49f9e5 100644 --- a/studio/frontend/src/features/training/api/models-api.ts +++ b/studio/frontend/src/features/training/api/models-api.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; +import { hubTokenHeader } from "@/features/hub"; interface VisionCheckResponse { model_name: string; @@ -98,10 +99,10 @@ export async function checkVisionModel( hfToken?: string | null, ): Promise { const encoded = encodeURIComponent(modelName); - const query = hfToken?.trim() ? `?hf_token=${encodeURIComponent(hfToken.trim())}` : ""; - const response = await authFetch(`/api/models/check-vision/${encoded}${query}`); + const response = await authFetch(`/api/models/check-vision/${encoded}`, { + headers: hubTokenHeader(hfToken?.trim() || null), + }); if (!response.ok) { - // If the check fails (e.g. network error), default to non-vision return false; } const data = (await response.json()) as VisionCheckResponse; @@ -114,10 +115,10 @@ export async function checkEmbeddingModel( hfToken?: string | null, ): Promise { const encoded = encodeURIComponent(modelName); - const query = hfToken?.trim() ? `?hf_token=${encodeURIComponent(hfToken.trim())}` : ""; - const response = await authFetch(`/api/models/check-embedding/${encoded}${query}`); + const response = await authFetch(`/api/models/check-embedding/${encoded}`, { + headers: hubTokenHeader(hfToken?.trim() || null), + }); if (!response.ok) { - // If the check fails (e.g. network error), default to non-embedding return false; } const data = (await response.json()) as EmbeddingCheckResponse; @@ -130,8 +131,10 @@ export async function getModelConfig( hfToken?: string, ): Promise { const encoded = encodeURIComponent(modelName); - const params = hfToken ? `?hf_token=${encodeURIComponent(hfToken)}` : ""; - const response = await authFetch(`/api/models/config/${encoded}${params}`, { signal }); + const response = await authFetch(`/api/models/config/${encoded}`, { + headers: hubTokenHeader(hfToken), + signal, + }); if (!response.ok) { throw new Error(`Failed to fetch model config (${response.status})`); } From 9259937b2ea487ac75f9d9421119208adac96a0b Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 17 Jul 2026 13:38:07 +0000 Subject: [PATCH 2/7] Cap chat_template_override size on the model load path The validate endpoint rejects chat templates over 65,536 bytes, but a direct LoadRequest caller could still submit an arbitrarily large template for llama to parse. Enforce the same byte limit on LoadRequest.chat_template_override. --- studio/backend/models/inference.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 3ae974448e..efd04f02e6 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -19,6 +19,10 @@ from pydantic import ( ) +# Redefined from picker/schemas.py so this core schema needn't import the picker package. +MAX_CHAT_TEMPLATE_BYTES = 65_536 + + class LoadRequest(BaseModel): """Request to load a model for inference""" @@ -56,6 +60,10 @@ class LoadRequest(BaseModel): def normalize_blank_chat_template_override(cls, value: Optional[str]) -> Optional[str]: if value is not None and value.strip() == "": return None + if value is not None and len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES: + raise ValueError( + f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit." + ) return value cache_type_kv: Optional[str] = Field( From bdc1629fea78a8931949bd8164fafe24e34277d5 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 17 Jul 2026 13:38:07 +0000 Subject: [PATCH 3/7] Bound the protected key set during legacy config migration Protecting every just-migrated key during eviction meant a legacy store with more than MAX_ENTRIES entries could never be brought under budget, so enforceStorageBudget failed on every reload and the migration never completed. Cap the protected set to MAX_ENTRIES so an oversized legacy store migrates a bounded subset and marks itself complete. Also condense a few future-schema guard comments. --- .../model-config/per-model-config.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index 53a376e8b4..5c0cb97bad 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -139,8 +139,7 @@ function deleteOldestEvictableEntry( protectedKeys?: ReadonlySet, ): { key: string; value: StoredMap[string] } | null { for (const key of Object.keys(map)) { - // Never evict a future-schema entry an older client cannot interpret, - // matching the save/delete guards. + // Never evict a future-schema entry an older client can't interpret. if ( protectedKeys?.has(key) || storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION @@ -271,11 +270,16 @@ function migrateLegacyLoadSettingsOnce(): void { localStorage.setItem(LEGACY_MIGRATION_FLAG, "1"); return; } - // Protect the just-migrated entries during eviction. If the budget cannot - // fit them (e.g. storage is full of future-schema records an older client - // cannot evict), leave the flag unset so migration retries once space frees - // up rather than marking it complete and dropping the migrated config. - if (!enforceStorageBudget(map, new Set(migratedKeys))) { + // Protect the just-migrated entries from eviction, but cap the protected set + // to MAX_ENTRIES: an oversized legacy store would otherwise deadlock the + // budget loop and never finish migrating. On failure the flag stays unset so + // migration retries once space frees. + const protectedKeys = new Set( + migratedKeys.length > MAX_ENTRIES + ? migratedKeys.slice(0, MAX_ENTRIES) + : migratedKeys, + ); + if (!enforceStorageBudget(map, protectedKeys)) { return; } if (writeMap(map)) { @@ -489,8 +493,7 @@ function loadPerModelConfig( if (!key) { return null; } - // Never apply a future-schema record an older client cannot interpret, - // matching the save/delete/evict guards. + // Never apply a future-schema record an older client can't interpret. if (storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION) { return null; } @@ -548,8 +551,7 @@ export function deletePerModelConfig( ggufVariant?: string | null, ): boolean { const map = readMap(); - // Mirror savePerModelConfig: never let an older client destroy a - // future-schema entry it cannot interpret. + // Never let an older client destroy a future-schema entry it can't interpret. if (hasFutureConfigForModelVariant(map, modelId, ggufVariant)) { return false; } From 4895a7c67a28b766c22fd9a248493f84a96560fb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:39:48 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/models/inference.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index efd04f02e6..db509dd911 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -61,9 +61,7 @@ class LoadRequest(BaseModel): if value is not None and value.strip() == "": return None if value is not None and len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES: - raise ValueError( - f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit." - ) + raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.") return value cache_type_kv: Optional[str] = Field( From 709077c83c2e4e4f90dfef1e8f835e60aab456be Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 17 Jul 2026 13:44:07 +0000 Subject: [PATCH 5/7] Fast-path the chat-template size check on character count Reject an oversized chat_template_override on its character count (a lower bound on the UTF-8 byte length) before allocating the encoded bytes, so a multi-megabyte string is turned away without the intermediate encode. --- studio/backend/models/inference.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index db509dd911..f290028a37 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -58,9 +58,15 @@ class LoadRequest(BaseModel): @field_validator("chat_template_override") @classmethod def normalize_blank_chat_template_override(cls, value: Optional[str]) -> Optional[str]: - if value is not None and value.strip() == "": + if value is None: return None - if value is not None and len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES: + # Reject on character count first (a lower bound on the UTF-8 byte length) + # so an oversized template is rejected before allocating the encoded bytes. + if len(value) > MAX_CHAT_TEMPLATE_BYTES: + raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.") + if value.strip() == "": + return None + if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES: raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.") return value From 1b3a47b6268d6d30ac706df8f7a0f6995a8ea0dc Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 17 Jul 2026 23:18:52 +0000 Subject: [PATCH 6/7] Accept the HF token from the header or the legacy query param The model config, vision-check, and embedding-check endpoints now read the token from the X-Unsloth-HF-Token header (preferred) and fall back to the hf_token query param, matching /gguf-variants. This keeps older clients that still send the query param working after the backend updates, while new clients send the header and never put the token in the URL. --- studio/backend/routes/models.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 92dac00347..a6320fb3fa 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -1796,11 +1796,15 @@ def _get_model_size_bytes(model_name: str, hf_token: Optional[str] = None) -> Op @router.get("/config/{model_name:path}") async def get_model_config( model_name: str, - hf_token: Optional[str] = Depends(get_hf_token), + hf_token: Optional[str] = Query(None), + hf_token_header: Optional[str] = Depends(get_hf_token), current_subject: str = Depends(get_current_subject), ): """Get configuration for a specific model (wraps load_model_defaults).""" try: + # Prefer the header token; keep the query param as a fallback for older + # clients that predate the header (as /gguf-variants does). + hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token) if not is_local_path(model_name): resolved = resolve_cached_repo_id_case(model_name) if resolved != model_name: @@ -2517,7 +2521,8 @@ async def get_lora_base_model(lora_path: str, current_subject: str = Depends(get @router.get("/check-vision/{model_name:path}", response_model = VisionCheckResponse) async def check_vision_model( model_name: str, - hf_token: Optional[str] = Depends(get_hf_token), + hf_token: Optional[str] = Query(None), + hf_token_header: Optional[str] = Depends(get_hf_token), current_subject: str = Depends(get_current_subject), ): """ @@ -2526,6 +2531,7 @@ async def check_vision_model( This endpoint wraps the backend is_vision_model function. """ try: + hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token) logger.info(f"Checking if vision model: {model_name}") # Authenticate so a gated/private VLM classifies correctly (else 404 -> non-vision). is_vision = is_vision_model(model_name, hf_token = hf_token) @@ -2549,7 +2555,8 @@ async def check_vision_model( @router.get("/check-embedding/{model_name:path}", response_model = EmbeddingCheckResponse) async def check_embedding_model( model_name: str, - hf_token: Optional[str] = Depends(get_hf_token), + hf_token: Optional[str] = Query(None), + hf_token_header: Optional[str] = Depends(get_hf_token), current_subject: str = Depends(get_current_subject), ): """ @@ -2558,6 +2565,7 @@ async def check_embedding_model( This endpoint wraps the backend is_embedding_model function. """ try: + hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token) logger.info(f"Checking if embedding model: {model_name}") is_embedding = is_embedding_model(model_name, hf_token = hf_token) From 53836cc62ee2c31db6461f923fef275c8c93fc4c Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 17 Jul 2026 23:18:52 +0000 Subject: [PATCH 7/7] Never let legacy migration evict existing per-model configs Protecting the just-migrated keys during budget enforcement meant legacy entries could evict the user's existing current-schema configs to make room, and could still deadlock when unevictable future-schema records were present. Protect the existing entries instead and let only the just-migrated legacy entries be evicted when over budget: importing old settings never discards a newer config, and since existing entries already fit the budget it cannot deadlock. --- .../model-config/per-model-config.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index 5c0cb97bad..f1b4140718 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -262,6 +262,10 @@ function migrateLegacyLoadSettingsOnce(): void { return; } const map = readMapRaw(); + // Snapshot the user's existing entries before merging: legacy settings are + // imported only into spare budget and must never evict a config the user + // already saved in the current schema. + const existingKeys = new Set(Object.keys(map)); const migratedKeys = mergeLegacyEntries( map, legacy as Record, @@ -270,16 +274,11 @@ function migrateLegacyLoadSettingsOnce(): void { localStorage.setItem(LEGACY_MIGRATION_FLAG, "1"); return; } - // Protect the just-migrated entries from eviction, but cap the protected set - // to MAX_ENTRIES: an oversized legacy store would otherwise deadlock the - // budget loop and never finish migrating. On failure the flag stays unset so - // migration retries once space frees. - const protectedKeys = new Set( - migratedKeys.length > MAX_ENTRIES - ? migratedKeys.slice(0, MAX_ENTRIES) - : migratedKeys, - ); - if (!enforceStorageBudget(map, protectedKeys)) { + // Protect existing entries so only the just-migrated legacy entries are + // evicted when over budget: importing old settings never discards a newer + // config, and since existing entries already fit the budget this cannot + // deadlock. On failure the flag stays unset so migration retries. + if (!enforceStorageBudget(map, existingKeys)) { return; } if (writeMap(map)) {