Studio: quiet noisy logs, log real progress, and speed up Windows/macOS dataset prep (#7087)

* Studio: exclude /api/export/status from request access logs

The frontend polls /api/export/status every 5s to detect export start, so it
fires continuously even when idle. Each poll emitted an info request_completed
access line, making up most of the server access logs. Add it to _EXCLUDED_PATHS
alongside /api/train/status. The endpoint is unchanged; export state is still
logged by the export modules and streamed over SSE, so no signal is lost.

* Studio: collapse hub download-progress polls in the access log

download-status and gguf-download-progress (plus the dataset equivalents)
are polled about twice a second for the whole download, so each emitted an
info request_completed line. Add them to _QUIET_POLL_PATHS so they collapse
to one heartbeat line per 10s instead of one per poll.

* Studio: log hub download progress at 10% steps

The access log carried no real progress, only poll pings. Emit one
hub_download_progress line per 10% step from the shared snapshot progress
reader, so an active download shows actual percentage without a line per
poll. Throttled per job and resynced if the same download restarts.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: drop successful chat thread/project CRUD from the access log

A single chat turn fans out about twenty requests under /api/chat/threads
and /api/chat/projects (list, fetch, per-message forks, and the message
writes) that only reflect the UI re-rendering. Suppress their 2xx access
line so the log keeps the signal (generation, tool calls, code execution,
engine stats) and errors. Non-2xx on these paths still log.

* Studio: silence transformers torch_dtype deprecation warning

transformers logs "`torch_dtype` is deprecated! Use `dtype` instead!" once at
model-config load via logger.warning_once (logging, not warnings), so a warnings
filter cannot catch it. Attach a small logging.Filter in setup_logging, which
runs before any model config is parsed, to drop that record on the transformers
loggers that emit it.

* Studio: quiet inference load-progress polls and log throttled load progress

The frontend polls /api/inference/load-progress about twice a second for the
whole model load, so each emitted a request_completed line. Add it to
_QUIET_POLL_PATHS (heartbeat) and emit one inference_load_progress line per 10%
step from the load-progress route, so a load shows real percentage instead of a
line per poll.

* Studio: fully suppress download/load progress poll access lines

The download-status, download-progress, gguf-download-progress, active-downloads
and transport-status polls (model and dataset), plus inference load-progress,
fire ~2x/s for the whole download or load. Their progress is now reported by the
hub_download_progress / inference_load_progress events (and the viewer's progress
line), so the per-poll access line adds nothing. Drop it on 2xx and keep it on
errors, instead of the prior 10s heartbeat. Chat CRUD suppression is folded into
the same _is_quiet_success helper.

* Studio: suppress training-tab model/dataset download-progress polls

The training tab polls /api/models/download-progress and
/api/datasets/download-progress about twice a second for the whole prep phase.
These are separate routes from the /api/hub equivalents and only scan the cache,
so their 2xx access line adds nothing (on Windows they always read 0 since the
bytes live in snapshots/, not blobs/). Suppress the 2xx line and keep errors,
alongside /api/models/gguf-download-progress.

* Studio: drop transient pre-auth 401 on chat thread/project polls

On first load the SPA fires chat thread/project GETs before the initial token
refresh, so they 401 until /api/auth/refresh runs and the retries succeed. That
pre-auth 401 is a bootstrap artifact, not an error; suppress it alongside the
already-quiet 2xx line. Genuine 4xx/5xx on these paths, the download/load poll
401s, and all /api/auth/* still log.

* Studio: quiet tab-switch list polls and per-poll scan/reconnect logs

Switching between the Train, Export, and Chat tabs refetches list endpoints on a
timer, and each hit re-logs internal detail. Heartbeat /api/train/runs,
/api/models/checkpoints, /api/models/local and /api/rag/knowledge-bases (10s
window, first hit and errors still log), and downgrade two per-poll INFO lines to
debug: the checkpoints scan summary ("Found N training runs") and the
per-reconnect SSE resume line. The meaningful "replayed N missed steps" line,
logged only when steps were actually replayed, stays at info.

* Studio: enable tokenizer parallelism for dataset prep on Windows/macOS

TOKENIZERS_PARALLELISM was forced off everywhere to stop datasets' forked map()
workers from deadlocking, but that fork only happens on Linux. On spawn platforms
(Windows/macOS) dataset.map() runs in-process (dataset_map_num_proc returns None),
so disabling tokenizer parallelism leaves the fast tokenizer single-threaded and
dataset prep runs serially on one core. Keep it off on Linux (fork safety) and on
for spawn platforms, where there is no fork to deadlock. Measured ~7x faster
tokenization (12.5s -> 1.7s for 20k rows on a 32-core Windows box).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: log throttled training status to the server log

Training step/loss/epoch only went to the UI via SSE, so the server log showed
inference engine_stats and train/runs heartbeats but nothing about the actual
run. Emit one throttled training_progress line (step/total, percent, loss, epoch,
eta) from the CUDA event pump: the first step, then at most every 30s, plus the
final step, resyncing when a new run restarts the counter. Per-step UI streaming
is unchanged.

* Studio: quiet llama.cpp update-status polls and log throttled update progress

The prebuilt llama.cpp update polls /api/llama/update-status about twice a second
for the whole download and install. Suppress its 2xx access line (errors still
log) and emit one throttled llama_update_progress line per 10% step from the
status route, so the update shows progress without a line per poll. The existing
"llama update: installing" and "llama update: success" events still bracket it.

* Studio: quiet the export log-tail poll

The Export tab polls /api/export/logs about once a second to stream the export
subprocess output into the UI panel. Suppress its 2xx access line; the real
progress is already logged as event-driven "Export subprocess status: <phase>"
lines plus the subprocess start and checkpoint-loaded events, and errors still log.

* studio: keep errors and mutations visible in access-log suppression

Make the quiet-success access-log suppression GET-only so chat thread/project
mutations (POST/PUT/DELETE) still log; only their list-poll 2xx and the
transient pre-auth 401 are dropped.

Suppress /api/export/status 2xx only (move it out of the all-status exclude
set) so a 401/403/500 on it stays visible.

Legacy /api/models and /api/datasets download-progress polls emit no
hub_download_progress events, so heartbeat them via the 10s quiet-poll window
instead of suppressing outright, keeping download visibility (notably on
Linux). The event-emitting /api/hub download polls stay fully suppressed.

Update and extend the middleware tests to cover GET-only suppression, the
export-status error path, and the legacy download heartbeat.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: tighten access-log and training-progress comments

Comment-only pass: collapse the multi-line explanations in the logging
middleware and the throttled training-progress logger to fewer lines while
keeping the rationale. No behavior change.

* studio: log structured export_progress phases

Emit a structured export_progress event per phase (consolidated in the server
log like training and download progress) instead of a plain status string, and
add a phase milestone at the start of the heavy export step so the
merge/save/convert is visible in the server log, not only in the forwarded
stdout panel.

* Studio: reset training-progress log throttle on each new run

start_training rebuilds the per-run progress state but left _last_progress_log_ts/_last_progress_log_step at their prior values. A run started within 30s of a previous one whose last logged step matched the new run's first step would hit the step == prev short-circuit and drop the promised first training_progress line, then stay suppressed until the old 30s window expired. Reset both fields when a new job is accepted.

* Studio: keep post-bootstrap chat 401s visible in the access log

The chat thread/project 401 suppression dropped every GET 401 on those prefixes, so a genuine expired-session 401 vanished alongside the transient pre-auth race. Gate the 401 drop on a per-middleware bootstrap latch that flips once /api/auth/refresh first succeeds: before that the 401s are the pre-refresh race and are suppressed; after it any chat 401 is a real failure and logs. Add a test for the post-refresh case.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: limit chat access-log suppression to the exact list polls

The chat thread/project suppression matched by startswith, so it also dropped the 2xx access line for detail and message reads (/threads/{id}, /threads/{id}/messages, /threads/{id}/messages/{id}, /projects/{id}) that are not the high-frequency list polls, losing their access and latency logging. Match the two list paths exactly instead, so only the intended list polls (and their pre-auth 401 race) are suppressed while detail and message reads keep their access line. Add a regression test.

* Studio: reset inference load-progress throttle for each load

The load-progress throttle (_last_load_progress_step) is a module global that persisted across loads, so a cached or small load whose first sampled /api/inference/load-progress response already reported fraction=1.0 hit step == prev (10) from a prior completed load and emitted no inference_load_progress line, while that endpoint's access log is suppressed, leaving the new load with no progress signal. Arm the throttle at load initiation in _load_model_impl so each load's first step always logs. Add a regression test.

* Studio: tighten logging comments

Collapse a few verbose comments (tokenizer-parallelism note, torch_dtype filter, legacy download-poll heartbeat, chat list-path suppression) to fewer lines without changing intent or code.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-15 06:49:52 -07:00 committed by GitHub
commit a6aa4fff10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 407 additions and 24 deletions

View file

@ -377,9 +377,10 @@ class ExportOrchestrator:
if rtype == "status":
message = resp.get("message", "")
logger.info("Export subprocess status: %s", message)
# Surface status in the live log panel for high-level progress.
# One structured export_progress line per phase (consolidated in the
# server log, like training/download progress); also shown live.
if message:
logger.info("export_progress", phase = message)
self._append_log(
{
"stream": "status",

View file

@ -398,6 +398,19 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
# orchestrator spawns a fresh subprocess per checkpoint load, resetting it.
_log_forward_gate.set()
# Phase milestone so the heavy export step shows in the server log; the
# merge/save/convert itself only forwards stdout to the live panel.
_phase = {
"merged": f"Exporting merged model ({cmd.get('format_type', '16-bit (FP16)')})...",
"gguf": f"Exporting GGUF ({cmd.get('quantization_method', 'Q4_K_M')})...",
"lora": "Exporting LoRA adapter...",
"base": "Exporting base model...",
}.get(export_type, f"Exporting ({export_type})...")
_send_response(
resp_queue,
{"type": "status", "message": _phase, "ts": time.time()},
)
output_path: Any = None
try:
if export_type == "merged":

View file

@ -11,8 +11,10 @@ import os
import sys
import types
# Prevent tokenizer parallelism deadlocks when datasets forks.
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Off on Linux so datasets' forked map() workers can't deadlock. On spawn platforms
# (Windows/macOS) map() runs in-process, so keep the fast tokenizer's Rust threads on
# (the only parallelism single-process tokenize gets; off makes prep run serially).
os.environ["TOKENIZERS_PARALLELISM"] = "true" if sys.platform in ("win32", "darwin") else "false"
# Make compiled cache modules importable by any subprocess. On spawn platforms
# (Windows/macOS) spawned dataset.map() workers re-import top-level modules, and

View file

@ -774,6 +774,10 @@ class TrainingBackend:
self._should_stop = False
self._cancel_requested = False # True only for stop(save=False)
# Throttled training-status logging to the server log (not one line/step).
self._last_progress_log_ts: float = 0.0
self._last_progress_log_step: int = -1
# Training metrics (consumed by routes for SSE and /metrics)
self.loss_history: list = []
self.lr_history: list = []
@ -956,6 +960,10 @@ class TrainingBackend:
self._progress = TrainingProgress(
is_training = True, status_message = "Initializing training..."
)
# Reset the progress-log throttle so the new run always logs its first step,
# even if it starts within 30s of a prior run whose last logged step matches.
self._last_progress_log_ts = 0.0
self._last_progress_log_step = -1
self.loss_history.clear()
self.lr_history.clear()
self.step_history.clear()
@ -1831,6 +1839,37 @@ class TrainingBackend:
elif db_action == "finalize":
self._finalize_run_in_db(**db_action_kwargs)
if etype == "progress":
self._log_training_progress()
def _log_training_progress(self) -> None:
"""One throttled training-status line to the server log (the per-step stream
still goes to the UI via SSE): first step, then at most every 30s, plus the
final step; resyncs on a new run. Runs on the pump thread."""
p = self._progress
step = int(p.step or 0)
if step <= 0:
return
total = int(p.total_steps or 0)
is_final = total > 0 and step >= total
prev = self._last_progress_log_step
if step == prev:
return
now = time.monotonic()
if prev >= 0 and step > prev and not is_final and (now - self._last_progress_log_ts) < 30.0:
return
self._last_progress_log_ts = now
self._last_progress_log_step = step
logger.info(
"training_progress",
step = step,
total_steps = total or None,
percent = int(step * 100 / total) if total > 0 else None,
loss = round(p.loss, 4) if p.loss is not None else None,
epoch = round(p.epoch, 2) if p.epoch is not None else None,
eta_s = int(p.eta_seconds) if p.eta_seconds else None,
)
def _ensure_db_run_created(self) -> None:
"""Create the DB row if it doesn't exist yet. An in-progress flag lets only one
caller create at a time, and ``_db_run_created`` is published only after

View file

@ -2190,7 +2190,11 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
stop_queue: mp.Queue for stop commands from the parent.
config: Training config dict with all parameters.
"""
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Off on Linux (forked datasets map() workers deadlock otherwise); on spawn
# platforms map() is in-process, so keep tokenizer threads on for faster prep.
os.environ["TOKENIZERS_PARALLELISM"] = (
"true" if sys.platform in ("win32", "darwin") else "false"
)
os.environ["PYTHONWARNINGS"] = "ignore" # before imports
# HTTP-fallback respawn: disable Xet before any huggingface_hub import (the

View file

@ -12,6 +12,7 @@ summing stale blobs against the wrong total)."""
from __future__ import annotations
import asyncio
import threading
from pathlib import Path
from typing import Callable, Optional
@ -34,6 +35,28 @@ logger = get_logger(__name__)
# (repo_id, hf_token) -> (expected_total_bytes, expected_blob_hashes)
SnapshotMetadataResolver = Callable[[str, Optional[str]], "tuple[int, frozenset[str]]"]
# One progress log per 10% step per job, so an active download reports progress
# without emitting a line on every poll.
_progress_step_lock = threading.Lock()
_last_progress_step: dict[str, int] = {}
def _log_progress_step(job_key: str, repo_id: str, variant: Optional[str], progress: float) -> None:
step = int(progress * 10)
with _progress_step_lock:
last = _last_progress_step.get(job_key, -1)
if step == last:
return
_last_progress_step[job_key] = step
if step < last:
return # download restarted; resync without logging
logger.info(
"hub_download_progress",
repo_id = repo_id,
variant = variant or "",
percent = step * 10,
)
def _empty_progress(expected_bytes: int) -> dict:
return {
@ -215,6 +238,8 @@ def compute_snapshot_progress(
else 0
)
)
if force_active:
_log_progress_step(job_key, repo_id, variant, progress)
return {
"downloaded_bytes": display_downloaded_bytes,
"completed_bytes": display_completed_bytes,

View file

@ -17,6 +17,15 @@ import structlog
from loggers.handlers import filter_sensitive_data
class _DropTorchDtypeDeprecation(logging.Filter):
"""Drop transformers' once-per-run "`torch_dtype` is deprecated" warning_once.
It is emitted via logging (not warnings), so a warnings filter cannot catch it."""
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return not ("torch_dtype" in msg and "deprecated" in msg)
class LogConfig:
"""Structured logging configuration for the application."""
@ -72,4 +81,13 @@ class LogConfig:
cache_logger_on_first_use = True,
)
# Drop transformers' cosmetic "`torch_dtype` is deprecated" warning_once (see filter).
_dtype_filter = _DropTorchDtypeDeprecation()
for _name in (
"transformers.configuration_utils",
"transformers.modeling_utils",
"transformers.pipelines.base",
):
logging.getLogger(_name).addFilter(_dtype_filter)
return structlog.get_logger(service_name)

View file

@ -28,19 +28,26 @@ def _env_int(name: str, default: int) -> int:
return default
# Drop duplicate successful-GET access logs repeated within the window: the SPA
# fans one cache invalidation into many identical list fetches; only the first
# informs. Loading polls, mutations, and errors are unaffected. 0 = log all.
# Collapse identical GET/2xx logs within the window (the SPA fans one invalidation
# into many list fetches). Mutations and errors always log. 0 = off.
_ACCESS_LOG_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_DEDUP_MS", 300)
# Pure-liveness/UI polls whose access line carries no signal beyond "client still
# polling" (state changes are logged by their own modules). Collapsed to a longer
# heartbeat instead of one line per poll; first hit and any error still log. 0 = off.
# Liveness/UI polls whose line means only "still polling"; collapse to a longer
# heartbeat. First hit and errors still log. 0 = off.
_QUIET_POLL_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_POLL_DEDUP_MS", 10000)
_QUIET_POLL_PATHS = {
"/api/health",
"/api/auth/status",
"/api/inference/status",
"/api/inference/monitor",
# List polls the tabs refetch on a timer and on every tab switch.
"/api/train/runs",
"/api/models/checkpoints",
"/api/models/local",
"/api/rag/knowledge-bases",
# Legacy download polls emit no progress events (unlike /api/hub/*), so heartbeat them.
"/api/models/download-progress",
"/api/models/gguf-download-progress",
"/api/datasets/download-progress",
}
_DEDUP_MAP_MAX = 4096
_NATIVE_PATH_LEASE_RE = re.compile(
@ -62,6 +69,46 @@ _EXCLUDED_SUFFIXES = (
".woff2",
".ttf",
)
# GET polls whose 2xx line carries no signal (their progress/phase events and the UI
# do), so drop it entirely; non-2xx still logs. Only /api/hub download polls emit
# events; the legacy /api/models and /api/datasets ones heartbeat via _QUIET_POLL_PATHS.
_QUIET_SUCCESS_PATHS = {
"/api/inference/load-progress",
"/api/llama/update-status",
"/api/export/logs",
"/api/export/status",
"/api/hub/download-status",
"/api/hub/download-progress",
"/api/hub/gguf-download-progress",
"/api/hub/active-downloads",
"/api/hub/transport-status",
"/api/hub/datasets/download-status",
"/api/hub/datasets/download-progress",
"/api/hub/datasets/active-downloads",
"/api/hub/datasets/transport-status",
}
# The token-refresh route. Its first 2xx means the client has obtained a valid
# session, so from then on chat 401s are real failures and must stay visible.
_AUTH_REFRESH_PATH = "/api/auth/refresh"
# High-frequency chat list polls; their 2xx is covered by generation/tool-call/stats
# events. Exact paths only, so detail/message reads (/threads/{id}, .../messages,
# /projects/{id}) keep their logs. The pre-auth 401 race also fires on these polls.
_CHAT_LIST_PATHS = {
"/api/chat/threads",
"/api/chat/projects",
}
def _is_quiet_success(method: str, path: str, status_code: int, pre_auth: bool) -> bool:
"""GET-only. Suppress a 2xx poll line that carries no signal, plus a chat list
poll's transient pre-auth 401 (only in the bootstrap window before the first
successful token refresh). Mutations, real (post-refresh) auth failures, and
all other errors always log."""
if method != "GET":
return False
if 200 <= status_code < 300:
return path in _QUIET_SUCCESS_PATHS or path in _CHAT_LIST_PATHS
return pre_auth and status_code == 401 and path in _CHAT_LIST_PATHS
class LoggingMiddleware:
@ -71,14 +118,16 @@ class LoggingMiddleware:
self.app = app
# (method, path, query, status_code) -> monotonic ts of the last EMITTED log.
self._last_log: dict[tuple[str, str, bytes, int], float] = {}
# Flips True after the first successful /api/auth/refresh; before that, chat
# list-poll 401s are the transient bootstrap race and are suppressed.
self._auth_refreshed = False
def _is_redundant_repeat(
self, method: str, path: str, query: bytes, status_code: int, now: float
) -> bool:
"""True if an identical GET/2xx log fired < window ago. The query string
is part of the identity, so distinct query-driven GETs are not collapsed.
Mutations and non-2xx are never deduped. Quiet-poll paths use a longer
heartbeat window. Stamps only on emit, so steady polls still log."""
"""True if an identical GET/2xx log fired < window ago (query string is part
of the identity). Non-GET/non-2xx never dedup; quiet-poll paths use the longer
heartbeat. Stamps only on emit, so steady polls still log."""
if method != "GET" or not (200 <= status_code < 300):
return False
window_ms = _QUIET_POLL_DEDUP_MS if path in _QUIET_POLL_PATHS else _ACCESS_LOG_DEDUP_MS
@ -129,8 +178,16 @@ class LoggingMiddleware:
raise
else:
end_time = time.perf_counter()
if not excluded and not self._is_redundant_repeat(
scope["method"], path, scope.get("query_string", b""), status_code, end_time
if 200 <= status_code < 300 and path == _AUTH_REFRESH_PATH:
self._auth_refreshed = True
if (
not excluded
and not _is_quiet_success(
scope["method"], path, status_code, not self._auth_refreshed
)
and not self._is_redundant_repeat(
scope["method"], path, scope.get("query_string", b""), status_code, end_time
)
):
logger.info(
"request_completed",

View file

@ -3917,6 +3917,10 @@ async def load_model(
async def _load_model_impl(request: LoadRequest, fastapi_request: Request, current_subject: str):
from core.inference.llama_cpp import LlamaServerNotFoundError
# A new load starts here; arm the progress throttle so this load's first
# sampled step logs even if it reports 100% immediately (cached/small load).
_reset_load_progress_step()
native_grant_backed = False
model_log_label = request.model_path
try:
@ -5495,6 +5499,33 @@ async def get_status(current_subject: str = Depends(get_current_subject)):
raise HTTPException(status_code = 500, detail = "Failed to get status")
_load_progress_lock = threading.Lock()
_last_load_progress_step = -1
def _log_load_progress_step(fraction, phase):
"""One inference_load_progress line per 10% step, so a model load shows
progress without a line per poll. Reset per load by _reset_load_progress_step."""
global _last_load_progress_step
step = int(max(0.0, min(float(fraction), 1.0)) * 10)
with _load_progress_lock:
prev = _last_load_progress_step
if step == prev:
return
_last_load_progress_step = step
if step < prev:
return # load regressed/restarted mid-poll; resync without logging
logger.info("inference_load_progress", phase = phase or "", percent = step * 10)
def _reset_load_progress_step():
"""Arm the throttle for a new load so its first sampled step always logs,
even a cached load that already reports fraction=1.0 on the first poll."""
global _last_load_progress_step
with _load_progress_lock:
_last_load_progress_step = -1
@router.get("/load-progress", response_model = LoadProgressResponse)
async def get_load_progress(current_subject: str = Depends(get_current_subject)):
"""
@ -5513,7 +5544,9 @@ async def get_load_progress(current_subject: str = Depends(get_current_subject))
progress = llama_backend.load_progress()
if progress is None:
return LoadProgressResponse()
return LoadProgressResponse(**progress)
resp = LoadProgressResponse(**progress)
_log_load_progress_step(resp.fraction, resp.phase)
return resp
except Exception as e:
logger.warning(f"Error sampling load progress: {e}")
return LoadProgressResponse()

View file

@ -14,14 +14,17 @@ never blocks on a missing marker / offline GitHub.
from __future__ import annotations
import asyncio
import threading
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from auth.authentication import get_current_subject
from loggers import get_logger
from utils.llama_cpp_update import get_update_status, start_update
logger = get_logger(__name__)
router = APIRouter()
@ -69,6 +72,27 @@ class LlamaUpdateActionResponse(BaseModel):
job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob)
_llama_update_lock = threading.Lock()
_last_llama_update_step = -1
def _log_llama_update_progress(job: LlamaUpdateJob) -> None:
"""One llama_update_progress line per 10% step so a prebuilt update reports
progress without a line per poll. Resyncs when a new update starts."""
global _last_llama_update_step
if job.state != "running" or job.progress is None:
return
step = int(max(0.0, min(float(job.progress), 1.0)) * 10)
with _llama_update_lock:
prev = _last_llama_update_step
if step == prev:
return
_last_llama_update_step = step
if step < prev:
return # new update; resync without logging
logger.info("llama_update_progress", to_tag = job.to_tag or "", percent = step * 10)
@router.get("/update-status", response_model = LlamaUpdateStatusResponse)
async def llama_update_status(
force_refresh: bool = Query(
@ -78,7 +102,9 @@ async def llama_update_status(
) -> LlamaUpdateStatusResponse:
# Off the event loop: detection may probe the host and read GitHub.
status = await asyncio.to_thread(get_update_status, force_refresh = force_refresh)
return LlamaUpdateStatusResponse(**status)
resp = LlamaUpdateStatusResponse(**status)
_log_llama_update_progress(resp.job)
return resp
@router.post("/update", response_model = LlamaUpdateActionResponse)

View file

@ -733,7 +733,9 @@ async def stream_training_progress(
if last_event_id is not None:
try:
resume_from_step = int(last_event_id)
logger.info(f"SSE reconnect: resuming from step {resume_from_step}")
# Fires on every reconnect (each tab switch); the meaningful signal is
# the "replayed N missed steps" line below, logged only when N > 0.
logger.debug(f"SSE reconnect: resuming from step {resume_from_step}")
except ValueError:
logger.warning(f"Invalid Last-Event-ID: {last_event_id}")

View file

@ -0,0 +1,48 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""The /api/inference/load-progress throttle: one line per 10% step, reset per load."""
import pytest
import routes.inference as ri
class _Capture:
def __init__(self):
self.events = []
def info(self, event, **kw):
self.events.append((event, kw))
@pytest.fixture
def cap(monkeypatch):
capture = _Capture()
monkeypatch.setattr(ri, "logger", capture)
ri._reset_load_progress_step()
return capture
def _percents(cap):
return [kw["percent"] for _event, kw in cap.events]
def test_new_load_first_step_logs_after_reset(cap):
# Load A reaches 100%.
ri._log_load_progress_step(1.0, "ready")
assert _percents(cap) == [100]
# Same value keeps deduping (steady poll on a finished load stays quiet).
ri._log_load_progress_step(1.0, "ready")
assert _percents(cap) == [100]
# A new load arms the throttle, so a cached load B that reports 100% on its
# first poll still emits its progress line instead of hitting step == prev.
ri._reset_load_progress_step()
ri._log_load_progress_step(1.0, "ready")
assert _percents(cap) == [100, 100]
def test_steady_poll_dedups_within_a_load(cap):
for _ in range(3):
ri._log_load_progress_step(0.3, "mmap")
assert _percents(cap) == [30] # one line per 10% step, not one per poll

View file

@ -135,7 +135,7 @@ def test_duplicate_get_within_window_deduped(logs, monkeypatch):
mw = LoggingMiddleware(app)
for _ in range(3):
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send))
_run(mw(_http_scope("/api/models/browse-folders"), _noop_receive, send))
# Only the first of the identical GET/200 burst is logged.
assert len(logs.events) == 1
@ -183,11 +183,11 @@ def test_quiet_poll_paths_use_longer_heartbeat_window(logs, monkeypatch):
for _ in range(3):
_run(mw(_http_scope("/api/inference/monitor"), _noop_receive, send)) # quiet
for _ in range(3):
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send)) # normal
_run(mw(_http_scope("/api/models/browse-folders"), _noop_receive, send)) # normal
paths = [e[2]["path"] for e in logs.events]
assert paths.count("/api/inference/monitor") == 1 # collapsed to one heartbeat
assert paths.count("/api/chat/projects") == 3 # base dedup off -> all logged
assert paths.count("/api/models/browse-folders") == 3 # base dedup off -> all logged
def test_distinct_query_strings_are_not_deduped(logs, monkeypatch):
@ -242,3 +242,118 @@ def test_fastapi_static_asset_success_skips_log(tmp_path, logs):
assert response.status_code == 200
assert response.text == "body { color: black; }"
assert len(logs.events) == log_count
def _status_app(status):
async def app(scope, receive, send):
await send({"type": "http.response.start", "status": status, "headers": []})
await send({"type": "http.response.body", "body": b""})
return app
async def _drop(message):
pass
def _paths_logged(logs):
return [e[2]["path"] for e in logs.events]
def test_quiet_success_get_2xx_suppressed(logs):
# A GET/2xx poll on a quiet-success path logs nothing; the signal is in events.
for path in ("/api/chat/threads", "/api/export/status", "/api/hub/download-status"):
_run(LoggingMiddleware(_status_app(200))(_http_scope(path), _noop_receive, _drop))
assert logs.events == []
def test_chat_detail_and_message_reads_still_log(logs):
# Only the exact list polls are suppressed; detail/message reads carry latency
# signal and keep their access line.
for path in (
"/api/chat/threads/abc123",
"/api/chat/threads/abc123/messages",
"/api/chat/threads/abc123/messages/m1",
"/api/chat/projects/p1",
):
_run(LoggingMiddleware(_status_app(200))(_http_scope(path), _noop_receive, _drop))
assert _paths_logged(logs) == [
"/api/chat/threads/abc123",
"/api/chat/threads/abc123/messages",
"/api/chat/threads/abc123/messages/m1",
"/api/chat/projects/p1",
]
def test_quiet_success_is_get_only(logs):
# Mutations on the same paths still log (suppression is GET-only).
for method in ("POST", "PUT", "DELETE"):
_run(
LoggingMiddleware(_status_app(200))(
_http_scope("/api/chat/threads", method = method), _noop_receive, _drop
)
)
assert len(logs.events) == 3
def test_chat_pre_auth_401_suppressed_other_errors_logged(logs):
# The transient bootstrap 401 on a chat list GET is dropped, but a 500 (or any
# other status) still logs so real failures stay visible.
_run(
LoggingMiddleware(_status_app(401))(_http_scope("/api/chat/projects"), _noop_receive, _drop)
)
assert logs.events == []
_run(
LoggingMiddleware(_status_app(500))(_http_scope("/api/chat/projects"), _noop_receive, _drop)
)
assert _paths_logged(logs) == ["/api/chat/projects"]
def test_chat_401_logged_after_first_auth_refresh(logs):
# A chat 401 before any successful token refresh is the bootstrap race and is
# dropped, but once /api/auth/refresh has succeeded on this instance later chat
# 401s are real failures and stay visible.
responses: dict[tuple[str, str], int] = {}
async def app(scope, receive, send):
status = responses.get((scope["method"], scope["path"]), 200)
await send({"type": "http.response.start", "status": status, "headers": []})
await send({"type": "http.response.body", "body": b""})
mw = LoggingMiddleware(app)
responses[("GET", "/api/chat/threads")] = 401
_run(mw(_http_scope("/api/chat/threads"), _noop_receive, _drop))
assert logs.events == [] # bootstrap race: suppressed
# A successful refresh (POST, always logged) closes the bootstrap window.
responses[("POST", "/api/auth/refresh")] = 200
_run(mw(_http_scope("/api/auth/refresh", method = "POST"), _noop_receive, _drop))
assert _paths_logged(logs) == ["/api/auth/refresh"]
# Now the same chat 401 is a real failure and logs.
_run(mw(_http_scope("/api/chat/threads"), _noop_receive, _drop))
assert _paths_logged(logs) == ["/api/auth/refresh", "/api/chat/threads"]
def test_export_status_error_still_logs(logs):
# 2xx suppressed, but an HTTP-level error on export status remains visible.
_run(
LoggingMiddleware(_status_app(200))(_http_scope("/api/export/status"), _noop_receive, _drop)
)
assert logs.events == []
_run(
LoggingMiddleware(_status_app(500))(_http_scope("/api/export/status"), _noop_receive, _drop)
)
assert _paths_logged(logs) == ["/api/export/status"]
def test_legacy_download_progress_heartbeats_not_suppressed(logs, monkeypatch):
# Legacy /api/models download polls emit no progress events, so they heartbeat
# (first hit logs, the burst collapses) rather than vanish entirely.
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 0)
monkeypatch.setattr(hmod, "_QUIET_POLL_DEDUP_MS", 1000)
mw = LoggingMiddleware(_status_app(200))
for _ in range(3):
_run(mw(_http_scope("/api/models/download-progress"), _noop_receive, _drop))
assert _paths_logged(logs) == ["/api/models/download-progress"]

View file

@ -248,7 +248,7 @@ def scan_checkpoints(
# Sort by modification time (newest first)
models.sort(key = lambda x: Path(x[1][0][1]).stat().st_mtime, reverse = True)
logger.info(f"Found {len(models)} training runs in {outputs_dir}")
logger.debug(f"Found {len(models)} training runs in {outputs_dir}")
return models
except Exception as e: