From 1e6b9aaca514d2d425bcb144c52d87ba24dd2e66 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:57:14 -0700 Subject: [PATCH 001/184] Fix UI font size scaling --- studio/frontend/package-lock.json | 1 + studio/frontend/package.json | 1 + .../src/components/shutdown-dialog.tsx | 4 +- .../stores/appearance-custom-store.ts | 7 +- .../sections/charts/eval-loss-chart-card.tsx | 9 +-- .../sections/charts/grad-norm-chart-card.tsx | 7 +- .../charts/learning-rate-chart-card.tsx | 5 +- .../charts/training-loss-chart-card.tsx | 9 +-- .../features/studio/sections/charts/utils.ts | 1 + .../studio/sections/training-section.tsx | 4 +- studio/frontend/src/index.css | 5 ++ studio/frontend/vite.config.ts | 72 ++++++++++++++++++- 12 files changed, 104 insertions(+), 21 deletions(-) diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 1d5c09ba72..b1b861840c 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -85,6 +85,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "postcss": "^8.5.15", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", "vite": "^8.0.16" diff --git a/studio/frontend/package.json b/studio/frontend/package.json index fc6911c4be..9846384133 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -104,6 +104,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "postcss": "^8.5.15", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", "vite": "^8.0.16" diff --git a/studio/frontend/src/components/shutdown-dialog.tsx b/studio/frontend/src/components/shutdown-dialog.tsx index e3e6ca9920..8f746c9414 100644 --- a/studio/frontend/src/components/shutdown-dialog.tsx +++ b/studio/frontend/src/components/shutdown-dialog.tsx @@ -52,8 +52,8 @@ export function ShutdownDialog({ onAfterShutdown?.(); document.body.innerHTML = `
-

Unsloth Studio has stopped.

-

You can now close this tab.

+

Unsloth Studio has stopped.

+

You can now close this tab.

`; }; diff --git a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts index f3618ddca5..9345a91f98 100644 --- a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts +++ b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts @@ -510,11 +510,14 @@ export function applyCustomizationToDocument( setVar("--custom-chat-font", null); } + // Scale typography without changing the root rem or layout dimensions. if (c.uiFontSize !== null && c.uiFontSize !== UI_FONT_SIZE_RANGE.default) { - style.fontSize = `${c.uiFontSize}px`; + setVar("--ui-font-scale", String(c.uiFontSize / UI_FONT_SIZE_RANGE.default)); } else { - style.removeProperty("font-size"); + setVar("--ui-font-scale", null); } + // Clear the root size used by older builds. + style.removeProperty("font-size"); if (c.codeFontSize !== null) { el.setAttribute("data-code-font-size", ""); diff --git a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx index b5eeac34f3..20b2eeeeab 100644 --- a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx @@ -17,6 +17,7 @@ import type { ReactElement } from "react"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { CHART_CONTAINER_CLASS, + CHART_FONT_SIZE, DEFAULT_CHART_MARGIN, DEFAULT_Y_AXIS_WIDTH, formatAxisMetric, @@ -70,7 +71,7 @@ export function EvalLossChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} tickFormatter={(value) => formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -81,7 +82,7 @@ export function EvalLossChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => formatAxisMetric(Number(value))} /> @@ -132,7 +133,7 @@ export function EvalLossChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} interval="preserveStartEnd" /> formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -89,7 +90,7 @@ export function GradNormChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => { const num = Number(value); diff --git a/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx index 1a7495b493..badc0f22dc 100644 --- a/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx @@ -16,6 +16,7 @@ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import type { ScaleMode } from "./types"; import { CHART_CONTAINER_CLASS, + CHART_FONT_SIZE, CHART_SYNC_ID, DEFAULT_CHART_MARGIN, DEFAULT_Y_AXIS_WIDTH, @@ -76,7 +77,7 @@ export function LearningRateChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} tickFormatter={(value) => formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -87,7 +88,7 @@ export function LearningRateChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => { const num = Number(value); diff --git a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx index 16113f3219..5acd90deba 100644 --- a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx @@ -22,8 +22,9 @@ import { } from "recharts"; import type { ScaleMode } from "./types"; import { - CHART_SYNC_ID, CHART_CONTAINER_CLASS, + CHART_FONT_SIZE, + CHART_SYNC_ID, DEFAULT_CHART_MARGIN, DEFAULT_Y_AXIS_WIDTH, formatAxisMetric, @@ -96,7 +97,7 @@ export function TrainingLossChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} tickFormatter={(value) => formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -107,7 +108,7 @@ export function TrainingLossChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => { const num = Number(value); @@ -152,7 +153,7 @@ export function TrainingLossChartCard({ value: formatMetric(avgRaw), }), position: "insideTopRight", - fontSize: 10, + fontSize: CHART_FONT_SIZE, fill: "#3b82f6", }} /> diff --git a/studio/frontend/src/features/studio/sections/charts/utils.ts b/studio/frontend/src/features/studio/sections/charts/utils.ts index 4a4a1f3b48..22f1169b41 100644 --- a/studio/frontend/src/features/studio/sections/charts/utils.ts +++ b/studio/frontend/src/features/studio/sections/charts/utils.ts @@ -9,6 +9,7 @@ export const DEFAULT_VISIBLE_POINTS = 160; export const CHART_CONTAINER_CLASS = "h-[220px] w-full"; export const DEFAULT_CHART_MARGIN = { top: 4, right: 8, bottom: 0, left: 4 }; export const DEFAULT_Y_AXIS_WIDTH = 45; +export const CHART_FONT_SIZE = "calc(10px * var(--ui-font-scale, 1))"; const TRAILING_ZEROES_RE = /\.?0+$/; const NEGATIVE_ZERO_RE = /^-0$/; diff --git a/studio/frontend/src/features/studio/sections/training-section.tsx b/studio/frontend/src/features/studio/sections/training-section.tsx index 5650b9c145..d33e6bf218 100644 --- a/studio/frontend/src/features/studio/sections/training-section.tsx +++ b/studio/frontend/src/features/studio/sections/training-section.tsx @@ -140,13 +140,13 @@ export function TrainingSection() { tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize="calc(10px * var(--ui-font-scale, 1))" /> { + if (!shouldScaleFontSize(declaration.value)) { + return; + } + declaration.value = `calc(${declaration.value} * var(${UI_FONT_SCALE_VAR}, 1))`; + }, + }, +}; + +// Scale named, arbitrary, and vendor font sizes in Tailwind's generated CSS. +function uiFontScalingPlugin(): VitePlugin { + return { + name: "unsloth-ui-font-scaling", + // Run after Tailwind and before Vite converts CSS into a module. + enforce: "pre", + async transform(code, id) { + if (!CSS_MODULE_ID_PATTERN.test(id)) { + return null; + } + const result = await postcss([scaleAbsoluteFontSizes]).process(code, { + from: id, + map: false, + }); + if (result.css === code) { + return null; + } + return { code: result.css, map: null }; + }, + }; +} // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), uiFontScalingPlugin()], optimizeDeps: { include: ["@dagrejs/dagre", "@dagrejs/graphlib"], }, From c267895538172d42ce9bc672484adafe487b4e5a Mon Sep 17 00:00:00 2001 From: Guerriero Riccardo <40391857+guerrieroriccardo@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:16:25 +0200 Subject: [PATCH 002/184] Studio: mask AMD GPU pins via ROCR so an unsupported iGPU can't crash llama-server (#7272) * Studio: mask AMD GPU pins via ROCR so an unsupported iGPU can't crash llama-server On a mixed AMD host (e.g. a discrete gfx1102 GPU next to a gfx1103 iGPU) the bundled rocm-gfx110X llama.cpp build segfaults during HSA device enumeration on the unsupported iGPU -- before llama-server prints a line, so every model load fails with a bare signal and empty logs. The GPU-subset pin masked visibility with HIP_VISIBLE_DEVICES, but HIP filtering runs only after the HSA runtime has already enumerated (and crashed on) every agent. Mask the subset via ROCR_VISIBLE_DEVICES (the ROCr/HSA layer) instead, so a deselected/unsupported GPU is never enumerated. Exactly one layer is masked (HIP cleared) to avoid the double-mask reindex that would otherwise drop the child to CPU. The whole-set tensor-split path and the CPU-only sentinel keep their existing HIP behavior. Also stop misreporting the resulting startup segfault as a vision projector incompatibility: when the text-only mmproj retry also hard- crashes with a signal, surface a GPU/driver init crash (with the ROCR hint) instead of blaming the projector. Co-Authored-By: Claude Opus 4.8 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten _emit_child_gpu_visibility comments for #7272 Comment and docstring only: condense the ROCR-vs-HIP masking rationale from ~22 to ~14 lines and the call-site note from 5 to 3, keeping every technical point (HSA enumeration segfault, physical ids, the -1 sentinel). Logic is unchanged, verified by an AST compare with docstrings stripped and by exercising _emit_child_gpu_visibility against a torch/HIP stub. * Detect AMD SDK ROCm wheels (hip=None) in _emit_child_gpu_visibility (Codex P2) The ROCm branch gated only on torch.version.hip, but AMD SDK wheels leave that unset while encoding 'rocm' in __version__ (detect_hardware handles this the same way). On such a wheel the masking was skipped entirely, leaving only CUDA_VISIBLE_DEVICES, so on a mixed AMD box the unsupported deselected iGPU still enumerated and could crash llama-server. Now the branch also treats 'rocm' in torch.__version__ as ROCm, mirroring detect_hardware. Adds tests for the hip=None SDK wheel (ROCR + default paths) and a CUDA guard so the version-string check can't false-positive. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remap CUDA_VISIBLE_DEVICES to post-ROCR ordinals on prefer_rocr for PR #7272 (Codex P1) On the prefer_rocr path _emit_child_gpu_visibility set ROCR_VISIBLE_DEVICES to the physical id and cleared HIP_VISIBLE_DEVICES, but left CUDA_VISIBLE_DEVICES at the physical id. ROCR re-indexes the visible agents from 0 and, with HIP cleared, HIP honours CUDA_VISIBLE_DEVICES -- so a non-zero pick (e.g. GPU 1) pointed out of range, HIP saw 0 devices, and the child fell back to CPU, defeating GPU-picker selections other than physical GPU 0. Remap CUDA to the post-ROCR ordinals (0..N-1); GPU 0 is unchanged, the default (HIP) path and the CPU sentinel are untouched, and non-AMD wheels never enter this branch. * Detect AMD SDK wheels in _resolve_visible_physical_ids for PR #7272 (Codex P2) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the HIP mask on Windows ROCm in prefer_rocr for PR #7272 (Codex P2) * Ignore ROCR_VISIBLE_DEVICES in _resolve_visible_physical_ids on Windows for PR #7272 (Codex P2) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Preserve inherited ROCR masks in the tensor-split pin for PR #7272 (Codex P2) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han Co-authored-by: Leo Borcherding --- studio/backend/core/inference/llama_cpp.py | 108 ++++++++-- studio/backend/tests/test_gpu_memory_mode.py | 205 ++++++++++++++++++- 2 files changed, 291 insertions(+), 22 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8651ed9ea8..1c9c76ebe9 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2912,12 +2912,25 @@ class LlamaCppBackend: on the ordinal->physical mapping.""" try: import torch - is_rocm = getattr(torch.version, "hip", None) is not None + + # Same ROCm detection as _emit_child_gpu_visibility: AMD SDK wheels + # leave version.hip unset but encode "rocm" in __version__. The two + # must agree, else an inherited ROCR mask reads back as "no mask", + # ordinal 0 is labelled physical 0, and the child's new ROCR pin + # re-exposes the GPU the inherited mask was hiding. + is_rocm = ( + getattr(torch.version, "hip", None) is not None + or "rocm" in getattr(torch, "__version__", "").lower() + ) except Exception: is_rocm = False if is_rocm: hip_v = os.environ.get("HIP_VISIBLE_DEVICES") - rocr_v = os.environ.get("ROCR_VISIBLE_DEVICES") + # ROCR_VISIBLE_DEVICES is a Linux ROCr variable; Windows HIP has no + # ROCr layer, so a stray ROCR var there does not mask the runtime and + # must not be read as the ordinal->physical mapping (mirrors the + # Windows gate in _emit_child_gpu_visibility). + rocr_v = None if sys.platform == "win32" else os.environ.get("ROCR_VISIBLE_DEVICES") cvd = ( hip_v if hip_v is not None @@ -2935,20 +2948,52 @@ class LlamaCppBackend: return None @staticmethod - def _emit_child_gpu_visibility(env: dict, pinned: str) -> None: - """Write the child's GPU visibility mask (CUDA, plus the HIP mirror on - ROCm, where narrowing only CUDA_VISIBLE_DEVICES leaves an AMD child - seeing the full set). Do NOT also set ROCR_VISIBLE_DEVICES: ROCR and HIP - mask at different layers, so the same indices apply twice -- ROCR reduces - and re-indexes from 0, then a non-zero HIP pin points out of range, HIP - enumerates 0 devices, and llama.cpp falls back to CPU. The HIP mask alone - narrows correctly; clear any inherited ROCR mask so it can't double up.""" + def _emit_child_gpu_visibility( + env: dict, + pinned: str, + *, + prefer_rocr: bool = False, + ) -> None: + """Write the child's GPU visibility mask: CUDA, plus a ROCm mirror on AMD + (masking only CUDA_VISIBLE_DEVICES leaves an AMD child seeing every GPU). + + Default: HIP_VISIBLE_DEVICES, clearing any inherited ROCR mask so the two + can't stack (ROCR re-indexes from 0, then a non-zero HIP pin points out of + range, HIP sees 0 devices, and llama.cpp falls back to CPU). + + prefer_rocr masks at the ROCr/HSA layer instead (clearing HIP). A HIP mask + filters only AFTER the HSA runtime enumerates every agent, and that + enumeration segfaults at startup on a GPU the build has no kernels for + (e.g. a gfx1103 iGPU under a gfx110X prebuilt), before llama-server logs a + line. ROCR drops the device at the driver layer, consuming physical ids. + The CPU-only sentinel ("-1") has no portable ROCR spelling, so it keeps + the HIP mask. Windows keeps the HIP mask too: ROCR_VISIBLE_DEVICES is a + Linux ROCr variable (Windows HIP has no ROCr layer), so the ROCR pin + would be dead there while the cleared HIP mask stops selecting.""" env["CUDA_VISIBLE_DEVICES"] = pinned try: import torch as _torch - if getattr(_torch.version, "hip", None) is not None: - env["HIP_VISIBLE_DEVICES"] = pinned - env.pop("ROCR_VISIBLE_DEVICES", None) + + # torch.version.hip is set on ROCm, None on CUDA; AMD SDK wheels may + # leave it unset but encode "rocm" in __version__ (mirrors detect_hardware). + if ( + getattr(_torch.version, "hip", None) is not None + or "rocm" in getattr(_torch, "__version__", "").lower() + ): + if prefer_rocr and pinned != "-1" and sys.platform != "win32": + env["ROCR_VISIBLE_DEVICES"] = pinned + env.pop("HIP_VISIBLE_DEVICES", None) + # ROCR re-indexes the visible agents from 0, and with HIP + # cleared HIP honours CUDA_VISIBLE_DEVICES -- so it must carry + # the post-ROCR ordinals (0..N-1), not the physical ids, else a + # non-zero pick points out of range and HIP sees 0 devices (the + # same stacking the default path avoids by clearing ROCR). + env["CUDA_VISIBLE_DEVICES"] = ",".join( + str(i) for i in range(len(pinned.split(","))) + ) + else: + env["HIP_VISIBLE_DEVICES"] = pinned + env.pop("ROCR_VISIBLE_DEVICES", None) except Exception as e: logger.debug("Failed to set ROCm visibility env vars for child: %s", e) @@ -2983,7 +3028,21 @@ class LlamaCppBackend: logger.debug("Could not read reported GPU order for split pin: %s", e) if order is None: order = sorted(inherited) - LlamaCppBackend._emit_child_gpu_visibility(env, ",".join(str(i) for i in order)) + # Re-emit at the layer that produced the mapping. A parent masked only + # via ROCR_VISIBLE_DEVICES hides agents at the driver layer, and the + # default HIP re-emission clears that mask -- HSA then enumerates every + # agent again and can segfault at startup on an unsupported GPU the + # parent was hiding (the crash prefer_rocr exists to avoid). Linux-only, + # mirroring _resolve_visible_physical_ids: on Windows a stray ROCR var + # is dead and was not the mapping's source. + prefer_rocr = ( + sys.platform != "win32" + and env.get("HIP_VISIBLE_DEVICES") is None + and env.get("ROCR_VISIBLE_DEVICES") is not None + ) + LlamaCppBackend._emit_child_gpu_visibility( + env, ",".join(str(i) for i in order), prefer_rocr = prefer_rocr + ) @staticmethod def _amd_apu_wants_unified_memory(gpu_indices = None) -> bool: @@ -7740,7 +7799,12 @@ class LlamaCppBackend: # default FASTEST_FIRST order (#5025). if gpu_ids: env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" - self._emit_child_gpu_visibility(env, ",".join(str(i) for i in gpu_indices)) + # Mask on AMD at the ROCr/HSA layer: HIP-only masking still + # enumerates every agent first, which segfaults on a deselected + # unsupported GPU (e.g. gfx1103 iGPU under a gfx110X prebuilt). + self._emit_child_gpu_visibility( + env, ",".join(str(i) for i in gpu_indices), prefer_rocr = True + ) elif manual_tensor_split_emitted and not is_vulkan_backend: # A manual per-GPU ratio across ALL GPUs (no explicit pick, so # no CUDA_VISIBLE_DEVICES mask above): the UI built the @@ -8102,6 +8166,20 @@ class LlamaCppBackend: # an OS-killed text-only retry still gets the OOM message. _retry_rc = self._process.poll() if self._process is not None else None self._kill_process() + # If the text-only retry ALSO hard-crashed (a signal, not + # OOM/timeout), the vision projector was never the cause: + # llama-server is faulting during GPU/driver init. Say so + # -- with the ROCm fix -- instead of blaming the mmproj. + if self._is_signal_crash(_retry_rc): + raise RuntimeError( + "llama-server crashed at startup on both the vision " + "and text-only attempts -- a GPU driver/runtime " + "initialization crash, not a model or vision-projector " + "problem. This often means an unsupported secondary " + "GPU; on AMD/ROCm, hide it with ROCR_VISIBLE_DEVICES " + "(e.g. ROCR_VISIBLE_DEVICES=0 exposes only the first " + "GPU) before launching Unsloth Studio." + ) raise RuntimeError( "Vision projector incompatible with this llama.cpp " "build, and the text-only retry also failed: " diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index b17274197f..19ba9e3e05 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -733,20 +733,211 @@ def test_split_pin_without_mask_only_sets_pci_order(monkeypatch): def test_split_pin_mirrors_hip_mask_on_rocm(monkeypatch): - # ROCm: the pin must land in HIP_VISIBLE_DEVICES too, and an inherited ROCR - # mask is cleared so the mask can't apply twice (ROCR re-indexes, then HIP - # would index into the already-reduced set). + # ROCm with the mask sourced from HIP: the pin must land in + # HIP_VISIBLE_DEVICES too, and an inherited ROCR mask is cleared so the + # mask can't apply twice (ROCR re-indexes, then HIP would index into the + # already-reduced set). _patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = [1, 3]) - torch_stub = _types.ModuleType("torch") - torch_stub.version = _types.SimpleNamespace(hip = "6.0") - monkeypatch.setitem(sys.modules, "torch", torch_stub) - env = {"CUDA_VISIBLE_DEVICES": "3,1", "ROCR_VISIBLE_DEVICES": "3,1"} + _rocm_torch_stub(monkeypatch) + env = { + "CUDA_VISIBLE_DEVICES": "3,1", + "HIP_VISIBLE_DEVICES": "3,1", + "ROCR_VISIBLE_DEVICES": "3,1", + } LlamaCppBackend._pin_visible_gpu_order_for_split(env) assert env["CUDA_VISIBLE_DEVICES"] == "1,3" assert env["HIP_VISIBLE_DEVICES"] == "1,3" assert "ROCR_VISIBLE_DEVICES" not in env +def test_split_pin_preserves_inherited_rocr_mask(monkeypatch): + # Mask sourced from ROCR alone (e.g. an AMD SDK parent): the pin must + # re-emit at the ROCr layer, not swap to HIP -- clearing ROCR re-exposes + # every agent to HSA enumeration, which can segfault at startup on an + # unsupported GPU the parent mask was hiding (#7272 review). CUDA carries + # the post-ROCR ordinals, mirroring the prefer_rocr emission. + _patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = [1, 3]) + _rocm_torch_stub(monkeypatch) + env = {"ROCR_VISIBLE_DEVICES": "3,1"} + LlamaCppBackend._pin_visible_gpu_order_for_split(env) + assert env["ROCR_VISIBLE_DEVICES"] == "1,3" + assert env["CUDA_VISIBLE_DEVICES"] == "0,1" + assert "HIP_VISIBLE_DEVICES" not in env + + +def test_split_pin_keeps_hip_on_windows_despite_stray_rocr(monkeypatch): + # On Windows the ROCR var is dead (no ROCr layer) and the resolver never + # reads it, so a stray value must not flip the pin to the ROCR emission: + # the HIP mask is the only effective selector there. + _patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = [1, 3]) + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = "6.0") + monkeypatch.setitem(sys.modules, "torch", torch_stub) + monkeypatch.setattr(sys, "platform", "win32") + env = {"CUDA_VISIBLE_DEVICES": "3,1", "ROCR_VISIBLE_DEVICES": "9"} + LlamaCppBackend._pin_visible_gpu_order_for_split(env) + assert env["CUDA_VISIBLE_DEVICES"] == "1,3" + assert env["HIP_VISIBLE_DEVICES"] == "1,3" + assert "ROCR_VISIBLE_DEVICES" not in env + + +def _rocm_torch_stub(monkeypatch): + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = "6.0") + monkeypatch.setitem(sys.modules, "torch", torch_stub) + # prefer_rocr is Linux-only (ROCR is an ROCr variable); pin the platform so + # these Linux-behaviour tests also pass on a Windows dev box. + monkeypatch.setattr(sys, "platform", "linux") + + +def test_subset_pin_masks_via_rocr_on_rocm(monkeypatch): + # A GPU-subset pin must exclude the rest at the ROCr/HSA layer: HIP masking + # still enumerates every agent first, which segfaults the build on an + # unsupported deselected GPU (e.g. a gfx1103 iGPU under a gfx110X prebuilt). + # ROCR drops it at the driver layer; only one mask is set (HIP cleared). + _rocm_torch_stub(monkeypatch) + env = {"HIP_VISIBLE_DEVICES": "9"} # stale/inherited HIP mask must not survive + LlamaCppBackend._emit_child_gpu_visibility(env, "0", prefer_rocr = True) + assert env["ROCR_VISIBLE_DEVICES"] == "0" + assert env["CUDA_VISIBLE_DEVICES"] == "0" + assert "HIP_VISIBLE_DEVICES" not in env + + +def test_prefer_rocr_remaps_cuda_to_post_rocr_ordinals(monkeypatch): + # ROCR re-indexes the visible agents from 0, and HIP (cleared here) falls back + # to CUDA_VISIBLE_DEVICES -- so on the prefer_rocr path CUDA must carry the + # post-ROCR ordinals, not the physical ids, else a non-zero pick indexes out + # of range and the child sees no GPU and drops to CPU (#7272 review). + _rocm_torch_stub(monkeypatch) + # Single non-zero GPU: ROCR keeps the physical id, CUDA becomes ordinal 0. + env = {} + LlamaCppBackend._emit_child_gpu_visibility(env, "1", prefer_rocr = True) + assert env["ROCR_VISIBLE_DEVICES"] == "1" + assert env["CUDA_VISIBLE_DEVICES"] == "0" + assert "HIP_VISIBLE_DEVICES" not in env + # Multi-GPU subset: ROCR keeps the physical ids, CUDA is the 0-based ordinals. + env = {} + LlamaCppBackend._emit_child_gpu_visibility(env, "1,3", prefer_rocr = True) + assert env["ROCR_VISIBLE_DEVICES"] == "1,3" + assert env["CUDA_VISIBLE_DEVICES"] == "0,1" + assert "HIP_VISIBLE_DEVICES" not in env + + +def test_subset_pin_default_still_uses_hip_and_clears_rocr(monkeypatch): + # Without prefer_rocr the masking is unchanged: HIP narrows, inherited ROCR + # is cleared so the two can't double-mask. + _rocm_torch_stub(monkeypatch) + env = {"ROCR_VISIBLE_DEVICES": "0,1"} + LlamaCppBackend._emit_child_gpu_visibility(env, "1") + assert env["HIP_VISIBLE_DEVICES"] == "1" + assert "ROCR_VISIBLE_DEVICES" not in env + + +def test_cpu_only_pin_keeps_hip_even_with_prefer_rocr(monkeypatch): + # The CPU-only sentinel never routes through ROCR (no portable "hide all" + # spelling); it hides every GPU via HIP. + _rocm_torch_stub(monkeypatch) + env = {} + LlamaCppBackend._emit_child_gpu_visibility(env, "-1", prefer_rocr = True) + assert env["HIP_VISIBLE_DEVICES"] == "-1" + assert "ROCR_VISIBLE_DEVICES" not in env + + +def _amd_sdk_torch_stub(monkeypatch): + # AMD SDK wheel: torch.version.hip is None but __version__ encodes rocm. + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = None) + torch_stub.__version__ = "2.9.1+rocm7.2.1" + monkeypatch.setitem(sys.modules, "torch", torch_stub) + monkeypatch.setattr(sys, "platform", "linux") + + +def test_prefer_rocr_falls_back_to_hip_on_windows(monkeypatch): + # ROCR_VISIBLE_DEVICES is a Linux ROCr variable (Windows HIP has no ROCr + # layer), so on Windows ROCm prefer_rocr must keep the HIP mask or a nonzero + # pick loses its only effective selector (#7272 review). + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = "6.0") + monkeypatch.setitem(sys.modules, "torch", torch_stub) + monkeypatch.setattr(sys, "platform", "win32") + env = {"ROCR_VISIBLE_DEVICES": "9"} + LlamaCppBackend._emit_child_gpu_visibility(env, "1", prefer_rocr = True) + assert env["HIP_VISIBLE_DEVICES"] == "1" + assert env["CUDA_VISIBLE_DEVICES"] == "1" + assert "ROCR_VISIBLE_DEVICES" not in env + + +def test_amd_sdk_wheel_hip_none_still_masks_rocr(monkeypatch): + # An AMD SDK wheel leaves torch.version.hip unset but has "rocm" in __version__. + # It must still get the ROCR mask, else only CUDA_VISIBLE_DEVICES is set and an + # unsupported iGPU keeps enumerating and can crash llama-server. + _amd_sdk_torch_stub(monkeypatch) + env = {"HIP_VISIBLE_DEVICES": "9"} + LlamaCppBackend._emit_child_gpu_visibility(env, "0", prefer_rocr = True) + assert env["ROCR_VISIBLE_DEVICES"] == "0" + assert "HIP_VISIBLE_DEVICES" not in env + + +def test_cuda_wheel_hip_none_gets_no_rocm_mask(monkeypatch): + # A CUDA wheel (hip=None, no "rocm" in __version__) must NOT get a HIP/ROCR mask + # -- only CUDA_VISIBLE_DEVICES -- so the version-string check can't false-positive. + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = None) + torch_stub.__version__ = "2.9.1+cu124" + monkeypatch.setitem(sys.modules, "torch", torch_stub) + env = {} + LlamaCppBackend._emit_child_gpu_visibility(env, "0", prefer_rocr = True) + assert env["CUDA_VISIBLE_DEVICES"] == "0" + assert "ROCR_VISIBLE_DEVICES" not in env + assert "HIP_VISIBLE_DEVICES" not in env + + +def test_resolve_physical_ids_reads_rocr_on_amd_sdk_wheel(monkeypatch): + # _resolve_visible_physical_ids must use the same ROCm detection as + # _emit_child_gpu_visibility: on an AMD SDK wheel (hip=None, rocm in + # __version__) an inherited ROCR mask IS the ordinal->physical mapping. + # Reading it as "no mask" labels ordinal 0 as physical 0 and the child's + # ROCR pin then re-exposes the GPU the mask was hiding (#7272 review). + _amd_sdk_torch_stub(monkeypatch) + for var in ("HIP_VISIBLE_DEVICES", "CUDA_VISIBLE_DEVICES"): + monkeypatch.delenv(var, raising = False) + monkeypatch.setenv("ROCR_VISIBLE_DEVICES", "1") + assert LlamaCppBackend._resolve_visible_physical_ids() == [1] + + +def test_resolve_physical_ids_ignores_rocr_on_cuda_wheel(monkeypatch): + # A CUDA wheel (hip=None, no "rocm") keeps CUDA-only semantics: a stray + # ROCR var must not be read as the mask. + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = None) + torch_stub.__version__ = "2.9.1+cu124" + monkeypatch.setitem(sys.modules, "torch", torch_stub) + for var in ("HIP_VISIBLE_DEVICES", "CUDA_VISIBLE_DEVICES"): + monkeypatch.delenv(var, raising = False) + monkeypatch.setenv("ROCR_VISIBLE_DEVICES", "1") + assert LlamaCppBackend._resolve_visible_physical_ids() is None + + +def test_resolve_physical_ids_ignores_rocr_on_windows(monkeypatch): + # ROCR_VISIBLE_DEVICES is a Linux ROCr variable: Windows HIP has no ROCr + # layer, so a stray ROCR var there does not mask the runtime. Reading it as + # the ordinal->physical mapping would label ordinal 0 with a stale ROCR id + # while the runtime still enumerates every adapter, so auto-selection could + # budget one card and pin another (#7272 review). HIP must still be honoured. + torch_stub = _types.ModuleType("torch") + torch_stub.version = _types.SimpleNamespace(hip = None) + torch_stub.__version__ = "2.9.1+rocm7.2.1" # AMD SDK wheel + monkeypatch.setitem(sys.modules, "torch", torch_stub) + monkeypatch.setattr(sys, "platform", "win32") + for var in ("HIP_VISIBLE_DEVICES", "CUDA_VISIBLE_DEVICES"): + monkeypatch.delenv(var, raising = False) + monkeypatch.setenv("ROCR_VISIBLE_DEVICES", "1") + assert LlamaCppBackend._resolve_visible_physical_ids() is None + # HIP precedence is unchanged on Windows. + monkeypatch.setenv("HIP_VISIBLE_DEVICES", "1") + assert LlamaCppBackend._resolve_visible_physical_ids() == [1] + + # ── Diffusion single-device selection ─────────────────────────────────────── From 978ae4745bf4d975abce6aa943ffad2f2d7aee1e Mon Sep 17 00:00:00 2001 From: Souravrajvi0 <144546710+Souravrajvi0@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:46:45 +0530 Subject: [PATCH 003/184] fix(install): infer Strix gfx when ROCm runtime is absent (#7305) * fix(install): infer Strix gfx when ROCm runtime is absent When /dev/kfd and rocminfo are missing on Linux (e.g. Arch/CachyOS Strix Halo), route to AMD per-arch wheels via cpuinfo/lspci inference instead of CPU-only PyTorch. Mirrors install.ps1 Windows behavior and fixes studio update via install_python_stack.py (unslothai#7301). * Map Radeon 8065S to gfx1151 in the Linux gfx inference (Codex P2) install.sh _infer_amd_gfx_arch_from_gpu_name missed 8065S, so a Strix Halo host that only exposes 'AMD Radeon 8065S' via lspci (no Ryzen AI Max branding in /proc/cpuinfo) was left on CPU torch. setup.sh and setup.ps1 already list 8065S -> gfx1151. Added it, and widened the cpuinfo regexes (install.sh and install_python_stack.py) from Radeon 80[0-9]0S to 80[0-9][05]S to match the 80X5S naming, consistent with the display-side check already in install.sh. Tests cover the 8065S name and the cpuinfo-only case. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate the Linux gfx inference out of WSL without the ROCDXG runtime for PR #7305 On WSL /proc/cpuinfo and lspci still see the host APU, so a standalone 'unsloth studio update' could infer gfx1151 and install per-arch ROCm wheels into a WSL env whose ROCDXG bridge (librocdxg) was never bootstrapped, i.e. one that cannot expose the GPU. Skip the cpuinfo/lspci inference on WSL unless librocdxg is present; an explicit UNSLOTH_ROCM_GFX_ARCH override still wins. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address Codex review on PR #7305 (WSL runtime gate, Linux mirror, arch guard) - install.sh _infer_linux_amd_gfx_arch: skip the cpuinfo/lspci inference on WSL unless librocdxg is present (the ROCDXG bridge), mirroring the Python fix, so a WSL box whose ROCm bootstrap was skipped keeps the CPU fallback instead of installing AMD wheels that cannot reach the GPU. The explicit UNSLOTH_ROCM_GFX_ARCH override still returns first, so it stays authoritative. - install.sh: guard the inferred-gfx reroute on x86_64|amd64. ROCm torch wheels are not published for arm64, so an inferred/overridden gfx no longer pushes an arm64 host to the AMD arch index (get_torch_index_url returns CPU there). - install_python_stack.py _amd_arch_index_url: honour UNSLOTH_AMD_ROCM_MIRROR on Linux (the same var install.sh uses) instead of the Windows mirror var, so a mirrored/air-gapped Linux 'unsloth studio update' reaches the index install.sh chose. Windows still delegates unchanged; both default to repo.amd.com. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scan all AMD display controllers in the lspci fallback for PR #7305 (Codex P2) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): keep inferred AMD wheels from being overwritten After a successful inferred-gfx install, skip the generic pytorch.org ROCm reinstall so readable ROCm userland without /dev/kfd cannot undo the per-arch repair (Codex P1 on #7305). Also merge latest main. * Only take the inferred-gfx install when the runtime sees no GPU for PR #7305 (Codex P1) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Isolate three updater tests from the host cpuinfo for PR #7305 (Strix dev box leak) * Gate the reroute on invisible ROCm and forward the inferred gfx to setup.sh for PR #7305 (Codex P2s) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Require AMD PCI display evidence for cpuinfo inference; honor gfx override with visible ROCm for PR #7305 (Codex P2s) --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: LeoBorcherding --- install.sh | 140 ++++++++ studio/install_python_stack.py | 189 ++++++++++- tests/studio/install/test_rocm_support.py | 393 +++++++++++++++++++++- 3 files changed, 714 insertions(+), 8 deletions(-) diff --git a/install.sh b/install.sh index e0f57c198b..963107524b 100755 --- a/install.sh +++ b/install.sh @@ -2144,6 +2144,92 @@ _amd_gpu_present_via_pci() { return 1 } +# Map a gfx arch to the AMD pip index family (mirrors install.ps1 $archFamilyMap). +_amd_arch_index_family_for_gfx() { + case "$1" in + gfx1201|gfx1200) echo gfx120X-all ;; + gfx1151) echo gfx1151 ;; + gfx1150) echo gfx1150 ;; + gfx1103|gfx1102|gfx1101|gfx1100) echo gfx110X-all ;; + gfx1036|gfx1035|gfx1034|gfx1033|gfx1032|gfx1031|gfx1030) echo gfx103X-all ;; + gfx90a) echo gfx90a ;; + gfx908) echo gfx908 ;; + *) return 1 ;; + esac +} + +# Map a GPU marketing name to gfx arch (kept in sync with install.ps1 nameArchTable). +_infer_amd_gfx_arch_from_gpu_name() { + case "$1" in + *"9070 XT"*|*9080*) echo gfx1201 ;; + *9070*|*9060*) echo gfx1200 ;; + *"8065S"*|*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) echo gfx1151 ;; + *"890M"*|*"880M"*|*"860M"*|*"840M"*|*"Strix Point"*|*"Krackan"*|*"HX 37"*|*"AI 9 HX"*|*"AI 9 36"*|*"AI 7 35"*|*"AI 5 34"*|*"AI 7 PRO 35"*|*"AI 5 33"*) echo gfx1150 ;; + *"RX 7600"*|*"RX 7700S"*|*"RX 7650"*|*"PRO W7600"*|*"PRO W7500"*|*"PRO V710"*) echo gfx1102 ;; + *"RX 7900"*|*"RX 7800"*|*"RX 7700"*|*"PRO W7900"*|*"PRO W7800"*|*"PRO W7700"*) echo gfx1100 ;; + *"780M"*|*"760M"*|*"740M"*|*"Phoenix"*|*"Hawk Point"*|*"Z1 Extreme"*|*"Z2 Extreme"*) echo gfx1103 ;; + *"RX 6900"*|*"RX 6800"*|*"RX 6750"*|*"RX 6700"*|*"PRO W6800"*|*"PRO W6900"*) echo gfx1030 ;; + *"RX 6650"*|*"RX 6600"*|*"PRO W6600"*|*"PRO W6650"*) echo gfx1032 ;; + *"RX 6500"*|*"RX 6400"*|*"RX 6300"*|*"PRO W6400"*|*"PRO W6500"*) echo gfx1034 ;; + *) return 1 ;; + esac +} + +# Best-effort gfx inference when ROCm tools can't see the GPU (unslothai#7301). +# Mirrors install.ps1 arch resolution on Windows ($HasROCm false, $ROCmGfxArch set). +_infer_linux_amd_gfx_arch() { + if [ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ]; then + printf '%s\n' "$(printf '%s' "$UNSLOTH_ROCM_GFX_ARCH" | tr '[:upper:]' '[:lower:]')" + return 0 + fi + # On WSL /proc/cpuinfo and lspci still report the host APU, but without the + # ROCDXG bridge (librocdxg over /dev/dxg) the AMD wheels can't reach the GPU; + # keep the CPU fallback there unless that runtime is present (the explicit + # override above still wins). Mirrors install_python_stack.py. + _gpu_evidence="" + if [ -e /dev/dxg ] || grep -qi microsoft /proc/version 2>/dev/null; then + for _d in /opt/rocm/lib /opt/rocm/lib64 /opt/rocm-*/lib /opt/rocm-*/lib64; do + { [ -e "$_d/librocdxg.so" ] || [ -e "$_d/librocdxg.so.1" ]; } && _rocdxg=1 && break + done + [ -n "${_rocdxg:-}" ] || return 1 + # WSL enumerates no PCI display device; /dev/dxg + librocdxg IS the + # GPU evidence there. + _gpu_evidence=1 + elif _amd_gpu_present_via_pci; then + _gpu_evidence=1 + fi + # /proc/cpuinfo leaks the HOST CPU model into VMs/containers that received + # no AMD GPU, so the CPU-model text alone is not GPU evidence: require an + # AMD display device (PCI vendor 0x1002, class 0x03*) before trusting it. + # The lspci fallback below needs no gate; an AMD display line IS evidence. + if [ -n "$_gpu_evidence" ] && grep -qiE 'Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo' /proc/cpuinfo 2>/dev/null; then + echo gfx1151 + return 0 + fi + if [ -n "$_gpu_evidence" ] && grep -qiE '890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33' /proc/cpuinfo 2>/dev/null; then + echo gfx1150 + return 0 + fi + if command -v lspci >/dev/null 2>&1; then + # A non-AMD controller can enumerate first (Intel/ASPEED before an AMD + # dGPU), so scan every display-class line and take the first AMD one + # that maps. The vendor guard is case-SENSITIVE (a -i "ATI" would match + # "CorporATIon" on every Intel/NVIDIA line); whole-line matching also + # survives the 0000: PCI domain prefix. Mirrors install_python_stack.py. + _amd_disp=$(lspci -nn 2>/dev/null | grep -E 'VGA compatible controller|3D controller|Display controller' | grep -E 'AMD|ATI' || true) + while IFS= read -r _ln; do + [ -n "$_ln" ] || continue + if _gfx=$(_infer_amd_gfx_arch_from_gpu_name "$_ln"); then + echo "$_gfx" + return 0 + fi + done </dev/null || true) + if [ -n "$_linux_inferred_gfx" ]; then + _amd_family=$(_amd_arch_index_family_for_gfx "$_linux_inferred_gfx") || _amd_family="" + if [ -n "$_amd_family" ]; then + _amd_mirror="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}" + while [ "${_amd_mirror%/}" != "$_amd_mirror" ]; do + _amd_mirror="${_amd_mirror%/}" + done + TORCH_INDEX_URL="${_amd_mirror}/${_amd_family}/" + # Hand the inferred arch to setup.sh (llama.cpp): it re-probes + # ROCm on its own, and on these runtime-less hosts its probes + # find nothing, so without this it classifies the box as + # non-ROCm and installs the CPU prebuilt while torch just got + # AMD per-arch wheels. setup.sh and install_llama_prebuilt.py + # both honor UNSLOTH_ROCM_GFX_ARCH, so exporting it is the + # whole handoff (a user-set override re-exports unchanged). + export UNSLOTH_ROCM_GFX_ARCH="$_linux_inferred_gfx" + case "$_linux_inferred_gfx" in + gfx1201|gfx1200|gfx1151|gfx1150) + TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0" + TORCHVISION_CONSTRAINT="torchvision>=0.26.0,<0.27.0" + TORCHAUDIO_CONSTRAINT="torchaudio>=2.11.0,<2.12.0" + ;; + esac + echo "" >&2 + echo " [WARN] ROCm runtime not visible (/dev/kfd, rocminfo, amd-smi) but $_linux_inferred_gfx inferred." >&2 + echo " [WARN] Routing to AMD arch-specific wheels ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))." >&2 + echo " [WARN] These wheels bundle their own ROCm runtime; install the kernel stack for native compute:" >&2 + echo " [WARN] https://docs.unsloth.ai/get-started/install-and-update/amd" >&2 + echo " [WARN] Tip: set UNSLOTH_ROCM_GFX_ARCH=$_linux_inferred_gfx to skip inference next time." >&2 + echo "" >&2 + fi + fi + ;; + esac +fi + # Export the resolved torch backend ("cuda", "rocm", or "cpu") so that # downstream scripts (setup.sh -> install_python_stack.py) know what was # chosen here and can skip ROCm-specific repair steps on CUDA/CPU hosts. diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index bb329e189e..a29ba0d7e5 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -769,6 +769,142 @@ def _gfx_arch_from_gpu_name(name: str) -> "str | None": return None +def _linux_amd_gfx_from_cpuinfo() -> "str | None": + """Infer gfx arch from /proc/cpuinfo on integrated AMD APUs (Strix Halo/Point).""" + try: + text = Path("/proc/cpuinfo").read_text(encoding = "utf-8", errors = "replace") + except OSError: + return None + if re.search(r"Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo", text, re.IGNORECASE): + return "gfx1151" + if re.search( + r"890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]" + r"|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33", + text, + re.IGNORECASE, + ): + return "gfx1150" + return None + + +def _linux_amd_gfx_from_lspci() -> "str | None": + """First AMD display-class lspci line mapping to a known gfx arch. A non-AMD + controller can enumerate first (Intel/ASPEED before an AMD dGPU), so scan + them all. The vendor guard is case-SENSITIVE: a -i "ATI" would match + "CorporATIon" on every Intel/NVIDIA line. Whole-line matching also survives + the 0000: PCI domain prefix.""" + lspci = shutil.which("lspci") + if not lspci: + return None + try: + result = subprocess.run( + [lspci, "-nn"], + stdout = subprocess.PIPE, + stderr = subprocess.DEVNULL, + text = True, + timeout = 10, + ) + except Exception: + return None + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + if not re.search(r"VGA compatible controller|3D controller|Display controller", line, re.I): + continue + if not re.search(r"AMD|ATI", line): + continue + arch = _gfx_arch_from_gpu_name(line) + if arch: + return arch + return None + + +def _is_wsl() -> bool: + """True on WSL, where the AMD GPU is reached via /dev/dxg (not /dev/kfd).""" + if os.path.exists("/dev/dxg"): + return True + try: + with open("/proc/version", encoding = "utf-8", errors = "replace") as fh: + return "microsoft" in fh.read().lower() + except OSError: + return False + + +def _wsl_rocm_runtime_present() -> bool: + """librocdxg (the WSL ROCDXG bridge that lets HIP reach the GPU over /dev/dxg) + under a ROCm lib dir. Its absence marks a WSL box whose ROCm was never set up.""" + dirs = ["/opt/rocm/lib", "/opt/rocm/lib64"] + dirs += glob.glob("/opt/rocm-*/lib") + glob.glob("/opt/rocm-*/lib64") + return any( + os.path.exists(os.path.join(d, so)) + for d in dirs + for so in ("librocdxg.so", "librocdxg.so.1") + ) + + +def _linux_amd_display_device_present() -> bool: + """Any AMD (vendor 0x1002) PCI display-class (0x03*) device in sysfs. + /proc/cpuinfo leaks the HOST CPU model into VMs/containers that received no + AMD GPU, so the CPU-model text alone is not GPU evidence; this is the + device-level check (mirrors install.sh _amd_gpu_present_via_pci).""" + try: + for dev in Path("/sys/bus/pci/devices").iterdir(): + try: + if (dev / "vendor").read_text().strip() != "0x1002": + continue + if (dev / "class").read_text().strip().startswith("0x03"): + return True + except OSError: + continue + except OSError: + pass + return False + + +def _infer_linux_amd_gfx_arch() -> "str | None": + """Infer gfx when ROCm runtime is absent but the host is a known AMD arch (unslothai#7301).""" + override = (os.environ.get("UNSLOTH_ROCM_GFX_ARCH") or "").strip().lower() + if override: + return override + if _is_wsl(): + # cpuinfo/lspci see the host APU even on a WSL box whose ROCDXG runtime + # was never bootstrapped; inferring there would install per-arch ROCm + # wheels into an env that still can't expose the GPU. Skip unless that + # runtime is present -- WSL enumerates no PCI display device, so + # /dev/dxg + librocdxg IS the GPU evidence there. + if not _wsl_rocm_runtime_present(): + return None + elif not _linux_amd_display_device_present(): + # Native Linux: a VM/container on a Strix host still shows the host CPU + # model in /proc/cpuinfo while receiving no AMD GPU, so require an AMD + # display device before trusting the CPU-model inference. The lspci + # fallback reads the same PCI space and would find nothing here either. + return None + cpu_gfx = _linux_amd_gfx_from_cpuinfo() + if cpu_gfx: + return cpu_gfx + return _linux_amd_gfx_from_lspci() + + +def _amd_arch_index_url(gfx_arch: str | None) -> str | None: + """Return the AMD per-arch pip index URL for a gfx arch (Linux + Windows). + + Windows honors UNSLOTH_ROCM_WINDOWS_MIRROR (via _windows_rocm_index_url); + Linux honors UNSLOTH_AMD_ROCM_MIRROR -- the same var install.sh uses -- so a + mirrored/air-gapped Linux repair reaches the index install.sh chose rather + than falling back to repo.amd.com. Both default to repo.amd.com when unset. + """ + if IS_WINDOWS: + return _windows_rocm_index_url(gfx_arch) + arch_family = _GFX_TO_AMD_INDEX_ARCH.get(gfx_arch or "") + if arch_family is None: + return None + base = (os.environ.get("UNSLOTH_AMD_ROCM_MIRROR") or "https://repo.amd.com/rocm/whl").rstrip( + "/" + ) + return f"{base}/{arch_family}/" + + def _windows_rocm_index_url(gfx_arch: str | None) -> str | None: """Return the AMD pip index URL for the given GPU arch, or None if unsupported.""" arch_family = _GFX_TO_AMD_INDEX_ARCH.get(gfx_arch or "") @@ -1647,22 +1783,24 @@ def _ensure_rocm_torch() -> None: # An explicit ROCm pin commits to ROCm wheels regardless of the visible GPU (headless / CI). # Mirror _ensure_cuda_torch: skip the NVIDIA/no-AMD/unreadable gates. _rocm_pin = _explicit_rocm_torch_index_url() + _inferred_linux_gfx = ( + _infer_linux_amd_gfx_arch() if (_rocm_pin is None and not IS_WINDOWS) else None + ) if _rocm_pin is None: # NVIDIA takes precedence on mixed hosts (only if a GPU is usable). if _has_usable_nvidia_gpu(): return # _has_rocm_gpu() (rocminfo / amd-smi rows) is the authoritative AMD-host signal; # the old /opt/rocm-or-hipcc gate broke runtime-only ROCm installs. - if not _has_rocm_gpu(): + if not _has_rocm_gpu() and not _inferred_linux_gfx: return # no AMD GPU visible ver = _detect_rocm_version() if ver is None: - if _rocm_pin is None: + if _rocm_pin is None and not _inferred_linux_gfx: print(" ROCm detected but version unreadable -- skipping torch reinstall") return - # Explicit pin: the pinned leaf drives the install, so an unreadable host version - # is fine (sentinel keeps ver comparisons defined). + # Explicit pin or inferred gfx: the index drives the install. ver = (0, 0) # Probe whether torch links against HIP, capturing the installed ROCm tag for pin-mismatch @@ -1712,6 +1850,44 @@ def _ensure_rocm_torch() -> None: rocm_torch_ready = has_hip_torch and not _rocm_pin_mismatch + # Inferred-gfx path: ROCm runtime missing but install.sh would route to AMD wheels. + # Gated on the runtime NOT enumerating a GPU: when it can, the runtime-visible + # arch (Strix override / generic below) decides, not cpuinfo -- a mixed Strix + # APU + dGPU box with HIP_VISIBLE_DEVICES on the dGPU must not get APU wheels. + # An explicit UNSLOTH_ROCM_GFX_ARCH is exempt from that runtime gate (mirrors + # install.sh): a visible GPU with an unreadable/unsupported ROCm version must + # not silently discard the user's named arch and leave CPU torch in place. + _gfx_override_env = (os.environ.get("UNSLOTH_ROCM_GFX_ARCH") or "").strip().lower() + if ( + _inferred_linux_gfx + and not has_hip_torch + and _rocm_pin is None + and (_gfx_override_env or not _has_rocm_gpu()) + ): + index_url = _amd_arch_index_url(_inferred_linux_gfx) + if index_url is not None: + _torch_pkg, _vision_pkg, _audio_pkg = _WINDOWS_ROCM_TORCH_PKG_SPECS.get( + _inferred_linux_gfx, ("torch", "torchvision", "torchaudio") + ) + print( + f"\n {_inferred_linux_gfx} inferred (ROCm runtime not visible) -- " + f"installing torch from {_strip_index_url_credentials(index_url)}\n" + f" AMD wheels bundle their own ROCm runtime; install the kernel stack " + f"for native GPU compute.\n" + ) + pip_install( + f"ROCm torch (inferred {_inferred_linux_gfx})", + "--force-reinstall", + "--no-cache-dir", + _torch_pkg, + _vision_pkg, + _audio_pkg, + "--index-url", + index_url, + constrain = False, + ) + rocm_torch_ready = True + # Strix Halo / Point (gfx1151 / gfx1150) need torch from AMD's per-gfx index # (2.11+rocm7.13); any generic pytorch.org rocm index lacks the fixes (ROCm 7.1 # segfaults in _grouped_mm). See _strix_needs_amd_arch_index for the floor gate. @@ -1776,8 +1952,11 @@ def _ensure_rocm_torch() -> None: constrain = False, ) rocm_torch_ready = True - elif not has_hip_torch or _rocm_pin_mismatch: + elif not rocm_torch_ready: # Reinstall when torch is not ROCm yet, OR a ROCm build's family differs from a pin. + # Gate on rocm_torch_ready (not has_hip_torch alone) so a successful inferred-gfx + # install above is not overwritten by the generic pytorch.org/rocmX.Y path -- that + # would undo the fresh-ROCm/no-/dev/kfd repair this path exists for (Codex P1 #7305). # Honour a ROCm pin verbatim; else pick the newest wheel tag <= host. _override_idx = _explicit_rocm_torch_index_url() if _override_idx is not None: diff --git a/tests/studio/install/test_rocm_support.py b/tests/studio/install/test_rocm_support.py index b343b07238..cd7b68f4b6 100644 --- a/tests/studio/install/test_rocm_support.py +++ b/tests/studio/install/test_rocm_support.py @@ -9,6 +9,7 @@ import subprocess import sys import tempfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, mock_open, patch, PropertyMock import pytest @@ -560,9 +561,13 @@ class TestDetectRocmVersion: class TestEnsureRocmTorch: """Verify ROCm torch reinstall logic.""" + # _infer_linux_amd_gfx_arch mocked to None: on a real Strix host the live + # /proc/cpuinfo would otherwise take the inferred-install path and break + # these "must not install" hosts (environment leak, not the code under test). @patch.object(stack_mod, "pip_install") @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) - def test_no_rocm_skips(self, mock_nvidia, mock_pip): + @patch.object(stack_mod, "_infer_linux_amd_gfx_arch", return_value = None) + def test_no_rocm_skips(self, mock_infer, mock_nvidia, mock_pip): """No ROCm toolchain should skip entirely.""" # Pin _detect_windows_gfx_arch to None so a real AMD test host's WMI # fallback can't defeat the "no ROCm anywhere" premise. @@ -572,6 +577,105 @@ class TestEnsureRocmTorch: _ensure_rocm_torch() mock_pip.assert_not_called() + @patch.object(stack_mod, "IS_WINDOWS", False) + @patch.object(stack_mod, "pip_install_try", return_value = True) + @patch.object(stack_mod, "pip_install") + @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) + @patch.object(stack_mod, "_has_rocm_gpu", return_value = False) + @patch.object(stack_mod, "_infer_linux_amd_gfx_arch", return_value = "gfx1151") + @patch.object(stack_mod, "_detect_rocm_version", return_value = None) + def test_inferred_gfx_without_rocm_runtime_installs_amd_index( + self, mock_ver, mock_infer, mock_gpu, mock_nvidia, mock_pip, mock_pip_try + ): + """Strix Halo without /dev/kfd must still get AMD gfx1151 wheels (unslothai#7301).""" + mock_probe = MagicMock() + mock_probe.returncode = 0 + mock_probe.stdout = b"|2.10.0+cpu\n" + with patch("os.path.isdir", return_value = True): + with patch("subprocess.run", return_value = mock_probe): + _ensure_rocm_torch() + torch_call = str(mock_pip.call_args_list[0]) + assert "gfx1151" in torch_call + assert "torch>=2.11.0,<2.12.0" in torch_call + + @patch.object(stack_mod, "IS_WINDOWS", False) + @patch.object(stack_mod, "pip_install_try", return_value = True) + @patch.object(stack_mod, "pip_install") + @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) + @patch.object(stack_mod, "_has_rocm_gpu", return_value = False) + @patch.object(stack_mod, "_infer_linux_amd_gfx_arch", return_value = "gfx1151") + @patch.object(stack_mod, "_detect_amd_gfx_codes", return_value = []) + @patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1)) + def test_inferred_gfx_not_overwritten_when_rocm_userland_readable( + self, mock_ver, mock_gfx, mock_infer, mock_gpu, mock_nvidia, mock_pip, mock_pip_try + ): + """Codex P1 #7305: after an inferred per-arch install, do not fall through to the + generic pytorch.org/rocmX.Y reinstall just because has_hip_torch is still False. + Readable ROCm userland without /dev/kfd is exactly the case that used to overwrite + the AMD gfx wheels.""" + mock_probe = MagicMock() + mock_probe.returncode = 0 + mock_probe.stdout = b"|2.10.0+cpu\n" + with patch("os.path.isdir", return_value = True): + with patch("subprocess.run", return_value = mock_probe): + _ensure_rocm_torch() + assert mock_pip.call_count == 1, mock_pip.call_args_list + torch_call = str(mock_pip.call_args_list[0]) + assert "gfx1151" in torch_call + assert "rocm7.1" not in torch_call + assert "download.pytorch.org" not in torch_call + + @patch.object(stack_mod, "IS_WINDOWS", False) + @patch.object(stack_mod, "pip_install_try", return_value = True) + @patch.object(stack_mod, "pip_install") + @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) + @patch.object(stack_mod, "_has_rocm_gpu", return_value = True) + @patch.object(stack_mod, "_infer_linux_amd_gfx_arch", return_value = "gfx1151") + @patch.object(stack_mod, "_detect_amd_gfx_codes", return_value = ["gfx1100"]) + @patch.object(stack_mod, "_detect_rocm_version", return_value = (7, 1)) + def test_inference_yields_to_runtime_visible_gpu( + self, mock_ver, mock_gfx, mock_infer, mock_gpu, mock_nvidia, mock_pip, mock_pip_try + ): + """When the runtime CAN enumerate a GPU, the cpuinfo inference must not + install wheels: a mixed Strix APU + dGPU box with the dGPU selected would + otherwise get gfx1151 wheels for a gfx1100 GPU. The runtime-visible arch + (Strix override / generic branch) decides instead.""" + mock_probe = MagicMock() + mock_probe.returncode = 0 + mock_probe.stdout = b"|2.10.0+cpu\n" + with patch("os.path.isdir", return_value = True): + with patch("subprocess.run", return_value = mock_probe): + _ensure_rocm_torch() + all_calls = str(mock_pip.call_args_list) + str(mock_pip_try.call_args_list) + assert "gfx1151" not in all_calls, all_calls + assert "rocm7.1" in all_calls, all_calls + + @patch.object(stack_mod, "IS_WINDOWS", False) + @patch.object(stack_mod, "pip_install_try", return_value = True) + @patch.object(stack_mod, "pip_install") + @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) + @patch.object(stack_mod, "_has_rocm_gpu", return_value = True) + @patch.object(stack_mod, "_detect_amd_gfx_codes", return_value = []) + @patch.object(stack_mod, "_detect_rocm_version", return_value = None) + def test_gfx_override_installs_despite_visible_rocm( + self, mock_ver, mock_gfx, mock_gpu, mock_nvidia, mock_pip, mock_pip_try + ): + """#7305 review: an explicit UNSLOTH_ROCM_GFX_ARCH is exempt from the + not-_has_rocm_gpu() gate (mirrors install.sh). A visible GPU with an + unreadable ROCm version must not silently discard the user's named arch + and leave CPU torch in place -- the per-arch install runs.""" + mock_probe = MagicMock() + mock_probe.returncode = 0 + mock_probe.stdout = b"|2.10.0+cpu\n" + with patch.dict(os.environ, {"UNSLOTH_ROCM_GFX_ARCH": "gfx1151"}): + with patch("os.path.isdir", return_value = True): + with patch("subprocess.run", return_value = mock_probe): + _ensure_rocm_torch() + assert mock_pip.call_count == 1, mock_pip.call_args_list + torch_call = str(mock_pip.call_args_list[0]) + assert "gfx1151" in torch_call + assert "download.pytorch.org" not in torch_call + @patch.object(stack_mod, "IS_WINDOWS", False) @patch.object(stack_mod, "pip_install_try", return_value = True) @patch.object(stack_mod, "pip_install") @@ -683,9 +787,10 @@ class TestEnsureRocmTorch: @patch.object(stack_mod, "pip_install") @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) @patch.object(stack_mod, "_has_rocm_gpu", return_value = True) + @patch.object(stack_mod, "_infer_linux_amd_gfx_arch", return_value = None) @patch.object(stack_mod, "_detect_rocm_version", return_value = None) def test_version_unreadable_prints_warning( - self, mock_ver, mock_gpu, mock_nvidia, mock_pip, capsys + self, mock_ver, mock_infer, mock_gpu, mock_nvidia, mock_pip, capsys ): """ROCm detected but version unreadable should print warning and skip.""" with patch("os.path.isdir", return_value = True): @@ -1042,7 +1147,8 @@ class TestEnsureRocmTorch: @patch.object(stack_mod, "pip_install") @patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = False) @patch.object(stack_mod, "_has_rocm_gpu", return_value = False) - def test_no_gpu_with_rocm_tools_skips(self, mock_gpu, mock_nvidia, mock_pip): + @patch.object(stack_mod, "_infer_linux_amd_gfx_arch", return_value = None) + def test_no_gpu_with_rocm_tools_skips(self, mock_infer, mock_gpu, mock_nvidia, mock_pip): """ROCm tools present but no actual AMD GPU should skip entirely.""" # Pin the Windows arch probe to None so a real AMD host's WMI fallback # can't defeat the "no actual GPU" premise. @@ -2122,6 +2228,7 @@ class TestGfxArchNameFallback: "name, expected", [ ("AMD Radeon(TM) 8060S Graphics", "gfx1151"), + ("AMD Radeon(TM) 8065S Graphics", "gfx1151"), ("AMD Ryzen AI MAX+ 395 w/ Radeon 8060S", "gfx1151"), ("AMD Radeon(TM) 890M", "gfx1150"), ("AMD Ryzen AI 9 HX 370 w/ Radeon 890M", "gfx1150"), @@ -3189,6 +3296,286 @@ _SETUP_SH_PATH = PACKAGE_ROOT / "studio" / "setup.sh" class TestStrixRocm71Override: """install.sh routes gfx1151/gfx1150 to AMD's arch index instead of ROCm 7.1 (_grouped_mm segfault).""" + def test_linux_gfx_inference_helpers_present(self): + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + assert "_infer_linux_amd_gfx_arch" in source + assert "_amd_arch_index_family_for_gfx" in source + assert "_amd_gpu_present_via_pci" in source + assert "unslothai#7301" in source + + def test_infer_linux_amd_gfx_from_cpuinfo(self): + assert stack_mod._linux_amd_gfx_from_cpuinfo is not None + with patch.object( + Path, + "read_text", + return_value = "model name : AMD Ryzen AI Max+ 395 w/ Radeon 8060S\n", + ): + assert stack_mod._linux_amd_gfx_from_cpuinfo() == "gfx1151" + # 8065S (Gorgon Halo) must match on the Radeon name alone, even without the + # "Ryzen AI Max" branding (mirrors setup.sh / setup.ps1 which list 8065S). + with patch.object(Path, "read_text", return_value = "model name : AMD Radeon 8065S\n"): + assert stack_mod._linux_amd_gfx_from_cpuinfo() == "gfx1151" + + def test_infer_gfx_gated_out_of_wsl_without_runtime(self): + """On WSL the cpuinfo/lspci inference must be skipped unless the WSL ROCDXG + runtime (librocdxg) is present: a bare `unsloth studio update` must not + install per-arch ROCm wheels into an env that still can't expose the GPU. + An explicit UNSLOTH_ROCM_GFX_ARCH override stays authoritative regardless.""" + m = stack_mod + with ( + patch.object(m, "_linux_amd_gfx_from_cpuinfo", return_value = "gfx1151"), + patch.object(m, "_linux_amd_gfx_from_lspci", return_value = None), + # PCI evidence present (the WSL branch never consults it anyway). + patch.object(m, "_linux_amd_display_device_present", return_value = True), + patch.dict(os.environ, {"UNSLOTH_ROCM_GFX_ARCH": ""}), + ): + # WSL + no runtime -> inference suppressed (CPU torch stays). + with ( + patch.object(m, "_is_wsl", return_value = True), + patch.object(m, "_wsl_rocm_runtime_present", return_value = False), + ): + assert m._infer_linux_amd_gfx_arch() is None + # WSL + runtime present (this dev box) -> inference still runs. + with ( + patch.object(m, "_is_wsl", return_value = True), + patch.object(m, "_wsl_rocm_runtime_present", return_value = True), + ): + assert m._infer_linux_amd_gfx_arch() == "gfx1151" + # Native Linux (not WSL) -> the gate never applies. + with ( + patch.object(m, "_is_wsl", return_value = False), + patch.object(m, "_wsl_rocm_runtime_present", return_value = False), + ): + assert m._infer_linux_amd_gfx_arch() == "gfx1151" + # Explicit override wins even on a bare WSL box (no runtime). + with ( + patch.object(m, "_is_wsl", return_value = True), + patch.object(m, "_wsl_rocm_runtime_present", return_value = False), + patch.dict(os.environ, {"UNSLOTH_ROCM_GFX_ARCH": "gfx1151"}), + ): + assert m._infer_linux_amd_gfx_arch() == "gfx1151" + + def test_infer_gfx_requires_amd_display_device_on_native_linux(self): + """A VM/container on a Strix host still shows the host CPU model in + /proc/cpuinfo while receiving no AMD GPU, so on native Linux the + CPU-model inference must require an AMD PCI display device (#7305 + review). WSL is exempt (no PCI enumeration there; the librocdxg gate is + the evidence) and the explicit override stays authoritative.""" + m = stack_mod + with ( + patch.object(m, "_linux_amd_gfx_from_cpuinfo", return_value = "gfx1151"), + patch.object(m, "_linux_amd_gfx_from_lspci", return_value = None), + patch.object(m, "_is_wsl", return_value = False), + patch.dict(os.environ, {"UNSLOTH_ROCM_GFX_ARCH": ""}), + ): + # No AMD display device -> the CPU-model text alone must not infer. + with patch.object(m, "_linux_amd_display_device_present", return_value = False): + assert m._infer_linux_amd_gfx_arch() is None + # Device present -> inference unchanged. + with patch.object(m, "_linux_amd_display_device_present", return_value = True): + assert m._infer_linux_amd_gfx_arch() == "gfx1151" + # Explicit override needs no device evidence (headless/cross-install). + with ( + patch.object(m, "_is_wsl", return_value = False), + patch.object(m, "_linux_amd_display_device_present", return_value = False), + patch.dict(os.environ, {"UNSLOTH_ROCM_GFX_ARCH": "GFX1151"}), + ): + assert m._infer_linux_amd_gfx_arch() == "gfx1151" + + def test_install_sh_cpuinfo_inference_requires_pci_evidence(self): + """install.sh mirror of the VM/container guard: both cpuinfo greps must be + gated on _gpu_evidence (AMD PCI display device via _amd_gpu_present_via_pci, + or the WSL librocdxg gate), and the gate must sit before the first grep.""" + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + body = _extract_sh_function_body(source, "_infer_linux_amd_gfx_arch") + assert body, "could not extract _infer_linux_amd_gfx_arch" + pci = body.find("_amd_gpu_present_via_pci") + infer = body.find("grep -qiE 'Ryzen AI Max") + assert pci >= 0 and infer >= 0 + assert pci < infer, "the PCI evidence check must run before the cpuinfo inference" + assert ( + body.count('[ -n "$_gpu_evidence" ] && grep -qiE') == 2 + ), "both cpuinfo greps (gfx1151 and gfx1150) must be gated on _gpu_evidence" + + def test_lspci_scan_covers_all_display_controllers(self): + """The lspci fallback must scan every display-class line, not just the + first: a non-AMD controller (Intel iGPU, ASPEED BMC) often enumerates + before the AMD dGPU. Non-AMD vendors must never map (an NVIDIA GeForce + GTX 860M would otherwise hit the AMD 860M pattern), and a 0000: PCI + domain prefix must not break matching.""" + m = stack_mod + + def fake_lspci(stdout): + result = SimpleNamespace(returncode = 0, stdout = stdout) + return ( + patch.object(m.shutil, "which", return_value = "/usr/bin/lspci"), + patch.object(m.subprocess, "run", return_value = result), + ) + + intel_then_amd = ( + "00:02.0 VGA compatible controller [0300]: Intel Corporation Raptor Lake-S GT1 [8086:a780]\n" + "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI]" + " Navi 31 [Radeon RX 7900 XT] [1002:744c]\n" + ) + nvidia_only = "01:00.0 3D controller [0302]: NVIDIA Corporation GM107M [GeForce GTX 860M] [10de:1392]\n" + domain_prefixed = ( + "0000:c5:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI]" + " Strix Halo [Radeon Graphics / Radeon 8060S] [1002:150e]\n" + ) + unmapped_then_mapped = ( + "03:00.0 Display controller [0380]: Advanced Micro Devices, Inc. [AMD/ATI]" + " Cape Verde [FirePro W600] [1002:6821]\n" + "04:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI]" + " Navi 33 [Radeon RX 7600] [1002:7480]\n" + ) + for stdout, expected in ( + (intel_then_amd, "gfx1100"), + (nvidia_only, None), + (domain_prefixed, "gfx1151"), + (unmapped_then_mapped, "gfx1102"), + ): + w, r = fake_lspci(stdout) + with w, r: + assert m._linux_amd_gfx_from_lspci() == expected, stdout + + def test_install_sh_lspci_scan_covers_all_display_controllers(self): + """install.sh mirror of the scan-all behaviour, executed with a shimmed + lspci: Intel-first still finds the AMD dGPU, NVIDIA-only maps nothing + (860M collision), a domain-prefixed AMD line still maps.""" + shell = shutil.which("bash") + if not shell: + pytest.skip("bash needed to execute the probe block") + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + name_fn = re.search( + r"^_infer_amd_gfx_arch_from_gpu_name\(\) \{\n.*?\n\}\n", source, re.S | re.M + ) + scan = re.search( + r"^ if command -v lspci[^\n]*\n.*?\nEOF\n fi\n return 1\n", source, re.S | re.M + ) + assert name_fn and scan, "could not extract the lspci scan block" + cases = ( + ( + "00:02.0 VGA compatible controller [0300]: Intel Corporation UHD [8086:a780]\n" + "03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI]" + " Navi 31 [Radeon RX 7900 XT] [1002:744c]", + "OK:gfx1100", + ), + ( + "01:00.0 3D controller [0302]: NVIDIA Corporation GM107M [GeForce GTX 860M] [10de:1392]", + "OK:", + ), + ( + "0000:c5:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc." + " [AMD/ATI] Strix Halo [Radeon 8060S] [1002:150e]", + "OK:gfx1151", + ), + ) + for lspci_out, expected in cases: + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "lspci") + with open(p, "w", encoding = "utf-8") as f: + f.write(f'#!/bin/sh\ncat <<"EOT"\n{lspci_out}\nEOT\n') + os.chmod(p, 0o755) + script = ( + "set -euo pipefail\n" + + name_fn.group(0) + + "probe() {\n" + + scan.group(0) + + "}\nprintf 'OK:%s\\n' \"$(probe || true)\"\n" + ) + env = dict(os.environ, PATH = d + os.pathsep + os.environ.get("PATH", "")) + r = subprocess.run([shell, "-c", script], env = env, capture_output = True, text = True) + assert r.returncode == 0, f"scan aborted: {r.stderr}" + assert ( + r.stdout.splitlines()[-1] == expected + ), f"lspci scan wrong for {lspci_out!r}: {r.stdout!r}" + + def test_install_sh_infer_gfx_gated_on_wsl_runtime(self): + """install.sh's _infer_linux_amd_gfx_arch must, like the Python side, skip + the cpuinfo/lspci inference on WSL unless librocdxg is present -- the + override still returns first, so it stays authoritative.""" + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + body = _extract_sh_function_body(source, "_infer_linux_amd_gfx_arch") + assert body, "could not extract _infer_linux_amd_gfx_arch" + override = body.find("UNSLOTH_ROCM_GFX_ARCH") + dxg = body.find("/dev/dxg") + rocdxg = body.find("librocdxg") + # Anchor on the first cpuinfo *inference* (the grep), not a comment mention. + infer = body.find("grep -qiE 'Ryzen AI Max") + assert override >= 0 and dxg >= 0 and rocdxg >= 0 and infer >= 0 + assert "microsoft" in body, "WSL gate must also detect WSL via /proc/version" + assert override < dxg, "the explicit override must return before the WSL gate" + assert ( + dxg < infer and rocdxg < infer + ), "the WSL/librocdxg gate must run before the cpuinfo/lspci inference" + + def test_install_sh_reroute_is_x86_64_only(self): + """The Linux inferred-gfx reroute must be x86_64-only: ROCm torch wheels are + not published for arm64, so an inferred/overridden gfx must not push an + arm64 host to the AMD arch index (get_torch_index_url returns CPU there).""" + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + idx = source.find("_linux_inferred_gfx=$(_infer_linux_amd_gfx_arch") + assert idx >= 0, "reroute consumer not found" + window = source[max(0, idx - 400) : idx] + assert ( + 'case "$_ARCH" in x86_64|amd64)' in window + ), "the inferred-gfx reroute must guard on x86_64|amd64 arch" + + def test_install_sh_reroute_skips_visible_rocm_gpu(self): + """A */cpu index on a host whose AMD GPU IS visible to the ROCm probes is a + deliberate fallback (unsupported/unreadable ROCm version, warned about in + get_torch_index_url), not a missing runtime: the reroute must not override + it with inferred per-arch wheels. The explicit UNSLOTH_ROCM_GFX_ARCH + override must still win either way.""" + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + idx = source.find("_linux_inferred_gfx=$(_infer_linux_amd_gfx_arch") + assert idx >= 0, "reroute consumer not found" + window = source[max(0, idx - 700) : idx] + assert ( + "! _has_amd_rocm_gpu" in window + ), "the reroute must be gated on _has_amd_rocm_gpu being false" + assert ( + '[ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ] || ! _has_amd_rocm_gpu' in window + ), "an explicit UNSLOTH_ROCM_GFX_ARCH override must bypass the visible-GPU gate" + + def test_install_sh_reroute_exports_gfx_for_setup_sh(self): + """The inferred arch must be exported as UNSLOTH_ROCM_GFX_ARCH so the + downstream setup.sh run (which re-probes ROCm independently and finds + nothing on these runtime-less hosts) routes llama.cpp to the matching + ROCm prebuilt instead of the CPU one -- setup.sh and + install_llama_prebuilt.py both read that env var.""" + source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") + assign = source.find('TORCH_INDEX_URL="${_amd_mirror}/${_amd_family}/"') + assert assign >= 0, "inferred-gfx index assignment not found" + block_end = source.find("esac", assign) + assert ( + 'export UNSLOTH_ROCM_GFX_ARCH="$_linux_inferred_gfx"' in source[assign:block_end] + ), "the reroute must export the inferred gfx for the setup.sh handoff" + # setup.sh's side of the handoff must still exist. + setup_source = (PACKAGE_ROOT / "studio" / "setup.sh").read_text(encoding = "utf-8") + assert "UNSLOTH_ROCM_GFX_ARCH" in setup_source + + def test_amd_arch_index_url_linux_honors_amd_mirror(self): + """On Linux the inferred-gfx repair must honour UNSLOTH_AMD_ROCM_MIRROR (the + var install.sh uses), not the Windows mirror var, so a mirrored/air-gapped + Linux install does not silently fall back to repo.amd.com. Windows still + delegates to the Windows mirror path.""" + m = stack_mod + with ( + patch.object(m, "IS_WINDOWS", False), + patch.dict(os.environ, {"UNSLOTH_AMD_ROCM_MIRROR": "https://mirror.local/rocm"}), + ): + assert m._amd_arch_index_url("gfx1151") == "https://mirror.local/rocm/gfx1151/" + with ( + patch.object(m, "IS_WINDOWS", False), + patch.dict(os.environ, {"UNSLOTH_AMD_ROCM_MIRROR": ""}), + ): + assert m._amd_arch_index_url("gfx1151") == "https://repo.amd.com/rocm/whl/gfx1151/" + assert m._amd_arch_index_url("gfx9999") is None + # Windows path is unchanged: delegate to the Windows mirror helper. + with patch.object(m, "IS_WINDOWS", True): + assert m._amd_arch_index_url("gfx1151") == m._windows_rocm_index_url("gfx1151") + def test_strix_gfx_detection_in_install_sh(self): """install.sh must detect gfx1151 and gfx1150 for the override.""" source = _INSTALL_SH_PATH.read_text(encoding = "utf-8") From 6f4c838281cef13bbb038426d3fdf53bb34c22de Mon Sep 17 00:00:00 2001 From: oobabooga Date: Thu, 23 Jul 2026 01:55:45 -0300 Subject: [PATCH 004/184] Studio: calibrate Linux chat typography against macOS (#7337) --- studio/frontend/src/index.css | 13 ++++- tests/studio/playwright_chat_ui.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 52ca81e064..1fafe09d17 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -633,11 +633,20 @@ html.no-font-smoothing body { -moz-osx-font-smoothing: auto; } -/* Match Inter's lighter macOS rendering. Keep 410 when smoothing is off or a - custom font reaches chat. */ +/* Match Inter's lighter macOS rendering. Dark surfaces need a stronger + correction than light surfaces. Keep 410 when smoothing is off or a custom + font reaches chat. */ html.render-linux:not(.no-font-smoothing):not([data-chat-font]):not([data-ui-font]) + :is(.aui-assistant-message-root, .aui-user-message-root) { + font-weight: 390; +} + +html.dark.render-linux:not(.no-font-smoothing):not([data-chat-font]):not([data-ui-font]) :is(.aui-assistant-message-root, .aui-user-message-root) { font-weight: 350; + /* The lighter variable-font instance has narrower advances. Reduce + dark-mode line-wrap drift without changing custom-font paths. */ + letter-spacing: 0.023em; } /* Chat font: only applies while a custom chat font is set. Elements with diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index 4d13889878..a06e559100 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -936,6 +936,70 @@ with sync_playwright() as p: page.keyboard.press("Escape") page.wait_for_timeout(300) + def read_chat_typography(): + """Read message typography after a user-driven theme transition.""" + return robust_evaluate( + page, + """() => { + const root = document.documentElement; + const assistant = Array.from( + document.querySelectorAll('.aui-assistant-message-root') + ); + const user = Array.from( + document.querySelectorAll('.aui-user-message-root') + ); + if (assistant.length === 0 || user.length === 0) { + return { error: 'chat message roots are missing' }; + } + const ua = navigator.userAgent.toLowerCase(); + const role = (nodes) => { + const styles = nodes.map((node) => getComputedStyle(node)); + return { + fontWeight: [...new Set(styles.map((style) => style.fontWeight))], + letterSpacing: [...new Set(styles.map((style) => style.letterSpacing))], + }; + }; + return { + actualRenderLinux: root.classList.contains('render-linux'), + isDesktopLinux: ua.includes('linux') && !ua.includes('android'), + isDark: root.classList.contains('dark'), + usesBaselineTypography: ( + root.classList.contains('no-font-smoothing') || + root.hasAttribute('data-chat-font') || + root.hasAttribute('data-ui-font') + ), + assistant: role(assistant), + user: role(user), + }; + }""", + ) + + def assert_chat_typography(label, typography): + if typography.get("error"): + fail(typography["error"]) + if typography["actualRenderLinux"] != typography["isDesktopLinux"]: + fail(f"desktop Linux detection mismatch: {typography!r}") + is_dark = typography["isDark"] + expected_spacing = "0.31px" if is_dark else "0.155px" + if typography["isDesktopLinux"] and not typography["usesBaselineTypography"]: + expected_weight = "350" if is_dark else "390" + if is_dark: + expected_spacing = "0.3565px" + else: + expected_weight = "410" + for role in ("assistant", "user"): + actual = typography[role] + if actual["fontWeight"] != [expected_weight]: + fail( + f"chat font weight {label}/{role}: expected {expected_weight}, " + f"got {actual['fontWeight']!r}" + ) + if actual["letterSpacing"] != [expected_spacing]: + fail( + f"chat letter spacing {label}/{role}: expected {expected_spacing}, " + f"got {actual['letterSpacing']!r}" + ) + # ───────────────────────────────────────────────────── # 9. Theme toggle -- multiple cycles + computed-bg-color check # (light is near-white >240; dark is near-black <40). @@ -944,6 +1008,7 @@ with sync_playwright() as p: if acct.count() > 0: step("theme toggle x3 with computed-color assertion") observed = [] + typography_states = [] for cycle in range(3): # Wait for any prior dropdown to fully detach: clicking while # the view-transition is still open no-ops silently. The @@ -1032,6 +1097,9 @@ with sync_playwright() as p: }""", ) observed.append(bg) + typography = read_chat_typography() + assert_chat_typography(f"theme-cycle-{cycle + 1}", typography) + typography_states.append(typography) shoot(f"10-theme-cycle-{cycle + 1}") info(f" cycle {cycle + 1}: dark={bg['isDark']} body bg={bg['bg']!r}") # Across cycles we should see both a near-white (light) and a @@ -1054,6 +1122,20 @@ with sync_playwright() as p: "(toggle may not flip on this runner's color-scheme)" ) + # These are user-driven theme transitions, not synthetic class + # changes. A completed three-cycle toggle must expose both typography + # states before we check the Linux selector. + if len(typography_states) != 3: + soft_fail( + f"chat typography observed {len(typography_states)} theme state(s), expected 3" + ) + elif {state["isDark"] for state in typography_states} != {False, True}: + soft_fail(f"chat typography did not observe both themes: {typography_states!r}") + else: + info("OK chat typography platform and theme behavior") + else: + soft_fail("chat typography requires the account-menu theme control") + # ───────────────────────────────────────────────────── # 10. Sidebar nav: New Chat, Compare, Search, Recipes. # ───────────────────────────────────────────────────── From d59c7bfd03c8fd93f194c91ac8307081349bab6d Mon Sep 17 00:00:00 2001 From: oobabooga Date: Thu, 23 Jul 2026 01:56:14 -0300 Subject: [PATCH 005/184] Studio: prevent login error text clipping (#7343) --- studio/frontend/src/features/auth/components/auth-form.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index 73db10d41b..3eec1dba88 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -439,7 +439,11 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { {helperText && (

{helperText}

)} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} ) : ( -
+
@@ -3329,7 +3329,7 @@ const PromptQueueStack: FC<{ queueThreadIds: string[] }> = ({ type="button" variant="ghost" size="sm" - className="h-7 w-[5.25rem] justify-center gap-1 px-0 text-sm font-normal text-muted-foreground/80 hover:text-foreground" + className="h-7 w-[84px] justify-center gap-1 px-0 text-sm font-normal text-muted-foreground/80 hover:text-foreground" onClick={() => startEditing(item)} > @@ -3565,14 +3565,14 @@ const DiffusionCanvas: FC = () => { canvas.total > 0 ? `step ${canvas.step + 1}/${canvas.total}` : "denoising"; return (
-
+
Denoising block {canvas.block + 1} - {stepLabel}
-
+      
         {canvas.text}
       
@@ -3646,7 +3646,7 @@ const AssistantMessage: FC = () => { return (
@@ -3676,7 +3676,7 @@ const AssistantMessage: FC = () => { ) : ( <>
- +
@@ -3759,7 +3759,7 @@ const ForkCountBadge: FC = () => { if (count <= 0) return null; return ( @@ -4084,7 +4084,7 @@ const UserMessageAudio: FC = () => { const UserMessage: FC = () => { return ( @@ -4195,7 +4195,7 @@ const BranchPicker: FC = ({ = ({ - + / diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-knowledge-base.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-knowledge-base.tsx index ec24060072..4fbaa227bd 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-knowledge-base.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-knowledge-base.tsx @@ -48,7 +48,7 @@ export function CitationBadge({ - + {errorText ?? (isStaleGeneratingArtifact ? "Refresh stopped this preview" diff --git a/studio/frontend/src/components/floating-monitor.tsx b/studio/frontend/src/components/floating-monitor.tsx index e38e2e5882..fb1ead3e63 100644 --- a/studio/frontend/src/components/floating-monitor.tsx +++ b/studio/frontend/src/components/floating-monitor.tsx @@ -102,7 +102,7 @@ export function FloatingMonitor() { initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} - className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-2rem)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none" + className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-32px)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none" >
@@ -139,7 +139,7 @@ export function FloatingMonitor() { className="space-y-3 overflow-hidden" >
-
+
{t("settings.resources.liveMonitor.ram")} -
+
{t("settings.resources.liveMonitor.vram")}{" "} {devices.length > 1 diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 3db15ffe30..25c413ad61 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -131,7 +131,7 @@ export function LlamaUpdateBanner({

-

+

{sizeLabel ? `${sizeLabel} download · ` : ""}No restart needed after update

@@ -209,7 +209,7 @@ export function LlamaUpdateBanner({
diff --git a/studio/frontend/src/components/tauri/update-screen.tsx b/studio/frontend/src/components/tauri/update-screen.tsx index 3199425f69..64f2e95a87 100644 --- a/studio/frontend/src/components/tauri/update-screen.tsx +++ b/studio/frontend/src/components/tauri/update-screen.tsx @@ -72,7 +72,7 @@ function LogViewer({ logs }: { logs: string[] }) { return (
{logs.map((line, i) => (
@@ -197,7 +197,7 @@ export function UpdateScreen({ readOnly value={manualReport} onFocus={(event) => event.currentTarget.select()} - className="mt-2 h-32 w-full max-w-xl resize-none rounded-lg border border-border/50 bg-muted/30 p-2 font-mono text-[10px] text-muted-foreground" + className="mt-2 h-32 w-full max-w-xl resize-none rounded-lg border border-border/50 bg-muted/30 p-2 font-mono text-[0.625rem] text-muted-foreground" /> )} diff --git a/studio/frontend/src/components/tauri/window-titlebar.tsx b/studio/frontend/src/components/tauri/window-titlebar.tsx index d5c74df463..66cc2e1b41 100644 --- a/studio/frontend/src/components/tauri/window-titlebar.tsx +++ b/studio/frontend/src/components/tauri/window-titlebar.tsx @@ -112,8 +112,8 @@ export function WindowTitlebar({ const { pinned, togglePinned } = useSidebarPin(); const sidebarWidth = showSidebarSurface ? pinned - ? "var(--studio-sidebar-expanded-width,17.5rem)" - : "var(--studio-sidebar-collapsed-width,3rem)" + ? "var(--studio-sidebar-expanded-width,280px)" + : "var(--studio-sidebar-collapsed-width,48px)" : "0px"; const contentBorderLeft = pinned ? `calc(${sidebarWidth} + 12px)` : "0px"; @@ -273,7 +273,7 @@ export function WindowTitlebar({ draggable={false} className="size-5 shrink-0 rounded-[6px] object-cover" /> - + Unsloth Studio
@@ -325,7 +325,7 @@ export function WindowTitlebar({ className="pointer-events-auto absolute top-0 h-full" style={{ left: sidebarWidth, - right: "calc(var(--studio-window-control-inset,112px) + 0.5rem)", + right: "calc(var(--studio-window-control-inset,112px) + 8px)", }} onMouseDown={handleDragMouseDown} onDoubleClick={handleDragDoubleClick} diff --git a/studio/frontend/src/components/ui/chart.tsx b/studio/frontend/src/components/ui/chart.tsx index 32da410bb5..25dc88d1cd 100644 --- a/studio/frontend/src/components/ui/chart.tsx +++ b/studio/frontend/src/components/ui/chart.tsx @@ -246,7 +246,7 @@ function ChartTooltipContent({ return (
diff --git a/studio/frontend/src/components/ui/copyable-error-chip.tsx b/studio/frontend/src/components/ui/copyable-error-chip.tsx index 595df5cf62..6f21b6d829 100644 --- a/studio/frontend/src/components/ui/copyable-error-chip.tsx +++ b/studio/frontend/src/components/ui/copyable-error-chip.tsx @@ -53,7 +53,7 @@ export function CopyableErrorChip({
diff --git a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx index 0345dc6e2a..82a236a387 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx @@ -145,12 +145,12 @@ export function ArtifactCard({ {isCode ? "HTML Code" : artifact.title} - + HTML canvas {isStreaming && !isCode ? ( - + Generating ) : null} diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index e59ce3a805..3daae8c50d 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -576,7 +576,7 @@ function CompareShell({ {children}
-
{composer}
+
{composer}
{showModelDisclaimer && (

LLMs can make mistakes. Double-check responses. @@ -651,7 +651,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ handleName="base" header={

- + Base Model
@@ -665,8 +665,8 @@ const LoraCompareContent = memo(function LoraCompareContent({ handleName="lora" borderClassName="border-t border-border/60 md:border-t-0 md:border-l" header={ -
- +
+ Fine-tuned
@@ -721,8 +721,8 @@ function GeneralCompareHeader({ side === "left" ? pinned ? "pl-12 pr-3 md:pl-2" - : "pl-12 pr-3 md:pl-[calc(0.5rem+max(0px,var(--studio-mac-traffic-light-inset,0px)-var(--sidebar-width-icon,3rem)))]" - : "pl-3 pr-[calc(3rem+var(--studio-chat-header-right-inset,var(--studio-window-control-inset,0px)))]", + : "pl-12 pr-3 md:pl-[calc(8px+max(0px,var(--studio-mac-traffic-light-inset,0px)-var(--sidebar-width-icon,48px)))]" + : "pl-3 pr-[calc(48px+var(--studio-chat-header-right-inset,var(--studio-window-control-inset,0px)))]", )} > {/* Slightly narrower than the composer max; every block shares this. */} -
+
-

+

{projectName}

@@ -1349,7 +1349,7 @@ function ProjectLanding({ type="button" onClick={() => setProjectTab("chats")} data-active={projectTab === "chats"} - className="h-10 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover" + className="h-10 rounded-full px-5 text-[0.875rem] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover" > Chats @@ -1357,7 +1357,7 @@ function ProjectLanding({ type="button" onClick={() => setProjectTab("sources")} data-active={projectTab === "sources"} - className="h-10 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover" + className="h-10 rounded-full px-5 text-[0.875rem] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover" > Sources @@ -1417,7 +1417,7 @@ function ProjectLanding({ onFocus={(event) => event.currentTarget.select()} maxLength={120} aria-label="Rename chat" - className="w-full border-0 bg-transparent text-[15px] font-semibold leading-5 text-foreground outline-none" + className="w-full border-0 bg-transparent text-[0.9375rem] font-semibold leading-5 text-foreground outline-none" />
@@ -1442,11 +1442,11 @@ function ProjectLanding({ className="flex min-h-[58px] min-w-0 flex-1 items-center gap-4 rounded-full px-4 py-2 text-left" >
-
+
{displayTitle}
- + {preview?.date ?? formatProjectChatDate(item.createdAt)} @@ -3105,14 +3105,14 @@ export function ChatPage({ )}
@@ -3141,7 +3141,7 @@ export function ChatPage({ /> )} {incognito && view.mode === "single" && ( -
+

When off, all connections are disabled.

@@ -1616,7 +1616,7 @@ export function ChatProvidersSettings({ {provider.name} - + {provider.models.length}{" "} {provider.models.length === 1 ? "model" : "models"} @@ -1631,7 +1631,7 @@ export function ChatProvidersSettings({ ) : null}
{modelSummary} @@ -1702,7 +1702,7 @@ export function ChatProvidersDialog({ Connections diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index d4f154882c..99d697f619 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -141,7 +141,7 @@ export function ParamSlider({
- + {label} {info && {info}} @@ -249,7 +249,7 @@ function CollapsibleSection({ }; const headerClasses = cn( - "flex w-full items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors focus-visible:outline-none focus-visible:ring-0", + "flex w-full items-center justify-between text-[0.75rem] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors focus-visible:outline-none focus-visible:ring-0", first ? "pt-4 pb-5" : "py-5", ); @@ -695,12 +695,12 @@ export function ChatSettingsPanel({ {/* Header is outside the scroll area so the scrollbar never shifts the close button. */}
{isMobile ? ( - + Run settings ) : ( <> - + Run settings @@ -740,7 +740,7 @@ export function ChatSettingsPanel({
{modelConfig} {showSpecFallback && ( -
+

{specFallbackReason === "mla_mtp_disabled" ? "MTP is disabled by default for this model architecture because it currently runs slower than standard decoding. Choose MTP in the model picker to force it." @@ -757,7 +757,7 @@ export function ChatSettingsPanel({ {mtpUpdatable && llamaUpdateStatus?.update_available && (

-

+

Use this for longer edits. Save writes back to the active configuration only. Insert variables with {"{{ env }}"}.

@@ -1230,16 +1230,16 @@ export function ChatSettingsPanel({
-
+
Prompt variables
-

+

Define values as JSON below, then use each key in your prompt, like {"{{ env }}"}.

- + Built-in, fill in automatically
@@ -1247,7 +1247,7 @@ export function ChatSettingsPanel({ {token} @@ -1272,11 +1272,11 @@ export function ChatSettingsPanel({ aria-invalid={Boolean(systemVariablesError)} /> {systemVariablesError ? ( -

+

{systemVariablesError}

) : ( -

+

Names you don't define are left unchanged, so a stray {" {{ typo }} "}stays visible in the prompt.

@@ -1288,7 +1288,7 @@ export function ChatSettingsPanel({ onChange={(event) => setSystemPromptDraft(event.target.value)} placeholder="You are a helpful assistant..." fieldSizing="fixed" - className="min-h-[20rem] max-h-[48vh] overflow-y-auto border-0 text-sm leading-6 corner-squircle focus-visible:ring-0" + className="min-h-[320px] max-h-[48vh] overflow-y-auto border-0 text-sm leading-6 corner-squircle focus-visible:ring-0" rows={14} />
@@ -1333,7 +1333,7 @@ export function ChatSettingsPanel({ if (isMobile) { return ( - + Run settings Chat inference settings @@ -1351,7 +1351,7 @@ export function ChatSettingsPanel({ data-tour="chat-settings" className={cn( "relative z-50 shrink-0 overflow-hidden bg-panel-surface text-panel-surface-fg font-heading", - open ? "w-[17rem] border-l border-sidebar-border" : "w-0", + open ? "w-[272px] border-l border-sidebar-border" : "w-0", )} style={{ height: "calc(100% - var(--studio-custom-titlebar-height, 0px))", @@ -1426,7 +1426,7 @@ function AutoHealToolCallsToggle() { return (
- + Auto-Healing Tool Calls @@ -1450,7 +1450,7 @@ function NudgeToolCallsToggle() { return (
- + Nudge Tool Calls @@ -1475,7 +1475,7 @@ function ConfirmToolCallsToggle() {
- + Confirm tool calls @@ -1487,7 +1487,7 @@ function ConfirmToolCallsToggle() {
{permissionMode === "full" ? ( - + Overridden by Full access ) : null} @@ -1508,7 +1508,7 @@ function BypassPermissionsToggle() { return (
- + Tool permissions @@ -1517,9 +1517,9 @@ function BypassPermissionsToggle() {
{/* Full width, styled like the panel selects/preset input. */} - + {permissionMode === "full" ? ( - + Tool calls run with no confirmation and no sandbox. ) : null} diff --git a/studio/frontend/src/features/chat/components/chat-search-dialog.tsx b/studio/frontend/src/features/chat/components/chat-search-dialog.tsx index dc3e1aac29..ea95040f44 100644 --- a/studio/frontend/src/features/chat/components/chat-search-dialog.tsx +++ b/studio/frontend/src/features/chat/components/chat-search-dialog.tsx @@ -83,7 +83,7 @@ export function ChatSearchDialog() { @@ -143,10 +143,10 @@ export function ChatSearchDialog() { strokeWidth={2} className="size-4 shrink-0 text-muted-foreground" /> - + {item.title || "Untitled chat"} - + {formatRelative(item.createdAt)} diff --git a/studio/frontend/src/features/chat/components/context-usage-bar.tsx b/studio/frontend/src/features/chat/components/context-usage-bar.tsx index 80f502e222..eeacef66df 100644 --- a/studio/frontend/src/features/chat/components/context-usage-bar.tsx +++ b/studio/frontend/src/features/chat/components/context-usage-bar.tsx @@ -71,7 +71,7 @@ export const ContextUsageBar: FC<{ : `Token usage: ${formatTokenCount(used)} tokens` } className={cn( - "flex items-center gap-2 rounded-[10px] px-2.5 py-1 font-mono text-chat-icon-fg text-[13px] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover", + "flex items-center gap-2 rounded-[10px] px-2.5 py-1 font-mono text-chat-icon-fg text-[0.8125rem] tabular-nums transition-colors hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover", className, )} > @@ -149,7 +149,7 @@ export const ContextUsageBar: FC<{
{hasKnownLimit && percent !== null && percent > 85 ? ( -
+
Close to the context limit. Generation will stop at 100%. Increase Context Length in the chat Settings panel to keep going. diff --git a/studio/frontend/src/features/chat/components/model-load-status.tsx b/studio/frontend/src/features/chat/components/model-load-status.tsx index 292c7884fd..613b5c260b 100644 --- a/studio/frontend/src/features/chat/components/model-load-status.tsx +++ b/studio/frontend/src/features/chat/components/model-load-status.tsx @@ -54,14 +54,14 @@ export function ModelLoadDescription({ {title ?

{title}

: null} {hasProgress ? (
-
+
{labelPrimary} {Math.round(clampProgress(progressPercent))}%
{labelSecondary ? ( -
+
{labelSecondary}
) : null} @@ -96,18 +96,18 @@ export function ModelLoadInlineStatus({ const hasProgress = typeof progressPercent === "number"; return ( -
+
{label}
{hasProgress ? (
-
+
{/* Tight inline layout: show only the primary (bytes) chunk; @@ -124,7 +124,7 @@ export function ModelLoadInlineStatus({ type="button" size="xs" variant="outline" - className="shrink-0 text-[11px]" + className="shrink-0 text-[0.6875rem]" onClick={onStop} > Stop diff --git a/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx b/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx index cb0234579b..04c88e4eba 100644 --- a/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx +++ b/studio/frontend/src/features/chat/components/openai-code-exec-section.tsx @@ -435,7 +435,7 @@ export function OpenAICodeExecSection({
@@ -459,7 +459,7 @@ export function OpenAICodeExecSection({ ACTIVE pill marks which one (no separate picker). */}
- + Containers
diff --git a/studio/frontend/src/features/chat/components/project-switcher.tsx b/studio/frontend/src/features/chat/components/project-switcher.tsx index 8f923a8c80..2a170e5a39 100644 --- a/studio/frontend/src/features/chat/components/project-switcher.tsx +++ b/studio/frontend/src/features/chat/components/project-switcher.tsx @@ -57,7 +57,7 @@ export function ProjectSwitcher({ className="size-icon shrink-0 text-foreground/70" /> - + {label} diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index b72a7a95c2..4b3f57b368 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -111,7 +111,7 @@ const MODEL_LOAD_TOAST_CLASSNAMES = { title: "leading-5", description: "mt-0 w-full", cancelButton: - "!h-auto !rounded-none !border-0 !bg-transparent !px-1 !text-[11px] !font-normal !text-muted-foreground hover:!bg-transparent hover:!text-destructive focus-visible:!text-destructive", + "!h-auto !rounded-none !border-0 !bg-transparent !px-1 !text-[0.6875rem] !font-normal !text-muted-foreground hover:!bg-transparent hover:!text-destructive focus-visible:!text-destructive", } as const; const MODEL_LOADED_TOAST_CLASSNAMES = { diff --git a/studio/frontend/src/features/chat/permission-mode-select.tsx b/studio/frontend/src/features/chat/permission-mode-select.tsx index e6c89cf54a..7e0ecb0c7e 100644 --- a/studio/frontend/src/features/chat/permission-mode-select.tsx +++ b/studio/frontend/src/features/chat/permission-mode-select.tsx @@ -120,7 +120,7 @@ export function PermissionModeMenuItems({ > - {option.label} + {option.label} {option.description} diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx index c9960ffaca..494368faec 100644 --- a/studio/frontend/src/features/chat/projects-page.tsx +++ b/studio/frontend/src/features/chat/projects-page.tsx @@ -367,7 +367,7 @@ export function ProjectsPage() { }} />
-

+

Projects

@@ -419,7 +419,7 @@ export function ProjectsPage() { Export All Projects - + Combined {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( @@ -430,7 +430,7 @@ export function ProjectsPage() { - + Per chat {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( @@ -445,7 +445,7 @@ export function ProjectsPage() { Export Projects + Recents - + Combined {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( @@ -456,7 +456,7 @@ export function ProjectsPage() { - + Per chat {EXPORT_FORMATS_LIST.map(({ fmt, label }) => ( @@ -482,7 +482,7 @@ export function ProjectsPage() { {!hasLoaded ? (
-
+
Name Modified @@ -526,7 +526,7 @@ export function ProjectsPage() {
{/* Column header. Name starts at the folder icon's left edge; the right-anchored columns keep Modified over its values. */} -
+
Name Modified @@ -571,7 +571,7 @@ export function ProjectsPage() { className="size-5" /> - + {project.name} diff --git a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx index 09c4944a14..4b815a7695 100644 --- a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx +++ b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx @@ -1341,7 +1341,7 @@ function ExportModal({ {/* */}
-

+

Export as

@@ -1390,7 +1390,7 @@ function ExportModal({

ShareGPT format for Unsloth fine-tuning

- + {`{"conversations":[{"from":"human","value":"..."},{"from":"gpt","value":""}]}`}
@@ -1400,7 +1400,7 @@ function ExportModal({ {/* */}
-

+

Format

@@ -1730,7 +1730,7 @@ function PromptListCard({
{entry.name} - + {entry.items.length}
@@ -1779,7 +1779,7 @@ function PromptListCard({

))} {entry.items.length > 3 && ( -

+

+{entry.items.length - 3} more

)} diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index f85c74eb86..4e2765bebd 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -266,7 +266,7 @@ export function ThreadSidebar({ > {item.isFork ? ( fork diff --git a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx index 088d016894..27584a646f 100644 --- a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx +++ b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx @@ -280,7 +280,7 @@ function LearningRecipeCards({ {badge} @@ -288,7 +288,7 @@ function LearningRecipeCards({ {extraLearningBadgeCount > 0 ? ( +{extraLearningBadgeCount} @@ -296,7 +296,7 @@ function LearningRecipeCards({ {isReady ? null : ( Soon @@ -403,7 +403,7 @@ export function DataRecipesPage(): ReactElement {
-

+

Data Recipes

diff --git a/studio/frontend/src/features/export/components/export-run-panel.tsx b/studio/frontend/src/features/export/components/export-run-panel.tsx index 6c2794420b..86c82935d6 100644 --- a/studio/frontend/src/features/export/components/export-run-panel.tsx +++ b/studio/frontend/src/features/export/components/export-run-panel.tsx @@ -278,7 +278,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {

onSaveDirectoryChange(e.target.value)} spellCheck={false} @@ -303,7 +303,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { Browse
-

+

{saveDirectory !== defaultSaveDirectory ? ( <>Default: {defaultSaveDirectory} ) : ( @@ -350,7 +350,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" - className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors" + className="flex items-center gap-1 text-[0.6875rem] text-emerald-600 hover:text-emerald-700 transition-colors" > Get token @@ -369,7 +369,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { onChange={(e) => onHfTokenChange(e.target.value)} /> -

+

Leave empty if already logged in via CLI.

@@ -427,7 +427,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { ) : null} {o.path} @@ -503,11 +503,11 @@ export function ExportRunPanel(props: ExportRunPanelProps) { {showProgress && (
- + {PHASE_LABELS[run.phase] ?? run.phase} {summaryMethod === "gguf" && run.quantTotal > 1 && ( - + Quant{" "} {Math.min( run.quantIndex + (isExporting ? 1 : 0), @@ -516,10 +516,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) { of {run.quantTotal} )} - + {progress}% - + {formatElapsed(elapsedSeconds)}
@@ -536,7 +536,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { /> {run.stage && (

{run.stage} @@ -552,7 +552,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) { -

+
{run.logLines.length === 0 ? (
diff --git a/studio/frontend/src/features/export/components/method-picker.tsx b/studio/frontend/src/features/export/components/method-picker.tsx index 420a7f6146..e240fd44ca 100644 --- a/studio/frontend/src/features/export/components/method-picker.tsx +++ b/studio/frontend/src/features/export/components/method-picker.tsx @@ -123,7 +123,7 @@ export function MethodPicker({ value, onChange, disabledMethods = [], disabledRe {m.badge && ( {m.badge} diff --git a/studio/frontend/src/features/export/components/quant-picker.tsx b/studio/frontend/src/features/export/components/quant-picker.tsx index 289f6498ce..688e5fb87f 100644 --- a/studio/frontend/src/features/export/components/quant-picker.tsx +++ b/studio/frontend/src/features/export/components/quant-picker.tsx @@ -61,7 +61,7 @@ export function QuantPicker({ value, onChange, sizes }: QuantPickerProps) { - + — select one or more
@@ -90,10 +90,10 @@ export function QuantPicker({ value, onChange, sizes }: QuantPickerProps) { )} {q.label} {sizeLabel && ( - {sizeLabel} + {sizeLabel} )} {q.recommended && !active && ( - + rec )} @@ -103,13 +103,13 @@ export function QuantPicker({ value, onChange, sizes }: QuantPickerProps) {
{value.length > 0 && (
- + {value.length} selected diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 3a970713ac..80235846d1 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -895,7 +895,7 @@ export function ExportPage() {
-

+

Export Model

@@ -964,21 +964,21 @@ export function ExportPage() { Local Model Fine-tuned Hugging Face @@ -1289,7 +1289,7 @@ export function ExportPage() { {model?.display_name ?? id} - + {source} @@ -1300,15 +1300,15 @@ export function ExportPage() {

{isLoadingLocalModels ? ( -

+

Scanning local models...

) : localModelsError ? ( -

+

{localModelsError}

) : ( -

+

{exportableLocalModels.length > 0 ? `${exportableLocalModels.length} local/cached models found` : "No local models found. Enter path manually."} @@ -1318,7 +1318,7 @@ export function ExportPage() { )}

-

+

Direct model exports currently support GGUF only.

@@ -1327,7 +1327,7 @@ export function ExportPage() { {sourceMode === "checkpoint" && (
- + Training Info
@@ -1374,7 +1374,7 @@ export function ExportPage() { key={step} className="flex items-start gap-2 text-xs text-muted-foreground" > - + {i + 1} {step} @@ -1422,7 +1422,7 @@ export function ExportPage() {
Precision
- + — select one or more
@@ -1479,7 +1479,7 @@ export function ExportPage() { {f.label} {f.needsCalibration ? " *" : ""} - + {f.hint} @@ -1492,7 +1492,7 @@ export function ExportPage() { {selectedFormats.length > 0 && (
- + {selectedFormats.length} selected:{" "} {selectedFormats .map( @@ -1506,7 +1506,7 @@ export function ExportPage() { @@ -1515,7 +1515,7 @@ export function ExportPage() { )} {hubMultiFormat && ( -
+
Hub export supports one format at a time (each writes to the repository root). Select a single format, or export locally to produce several at once. @@ -1527,13 +1527,13 @@ export function ExportPage() { MERGED_FORMATS.find((f) => f.value === v) ?.needsCalibration, ) && ( -
+
* calibrates on data (uses a small calibration set).
)} {!hasNvidia && ( -
+
No NVIDIA GPU detected: compressed-tensors formats are hidden. 16-bit and portable FP8/INT8 (torchao) still work here and load in vLLM. diff --git a/studio/frontend/src/features/hub/catalog/catalog-states.tsx b/studio/frontend/src/features/hub/catalog/catalog-states.tsx index a36c4bb2da..693b5b40c0 100644 --- a/studio/frontend/src/features/hub/catalog/catalog-states.tsx +++ b/studio/frontend/src/features/hub/catalog/catalog-states.tsx @@ -38,20 +38,20 @@ export function NetworkErrorState({
-

+

{title}

-

+

{body}

-

{message}

+

{message}

{onSwitchDevice ? ( @@ -59,7 +59,7 @@ export function NetworkErrorState({
-

+

No matches yet

-

+

Scanned {scannedCount.toLocaleString()} results. Load another page to keep searching Hugging Face.

@@ -105,7 +105,7 @@ export function DiscoverFetchMoreState({ @@ -114,7 +114,7 @@ export function DiscoverFetchMoreState({ type="button" onClick={onFetchMore} disabled={isLoadingMore} - className="inline-flex h-8 items-center gap-1.5 rounded-full bg-transparent px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/[0.05]" + className="inline-flex h-8 items-center gap-1.5 rounded-full bg-transparent px-3 text-[0.75rem] font-medium text-foreground transition-colors hover:bg-foreground/[0.04] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/[0.05]" > {/* Only warn about hidden results when a filter is actually narrowing them. */} {hasActiveFilters && ( -

+

Some results may be hidden by your filters.

)} @@ -149,7 +149,7 @@ export function DiscoverFetchMoreFooter({ type="button" onClick={onFetchMore} disabled={isLoadingMore} - className="inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground/[0.06] px-3 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/[0.06] dark:hover:bg-white/[0.1]" + className="inline-flex h-8 items-center gap-1.5 rounded-full bg-foreground/[0.06] px-3 text-[0.75rem] font-medium text-foreground transition-colors hover:bg-foreground/[0.1] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/[0.06] dark:hover:bg-white/[0.1]" >
-

+

Couldn't load your library

-

+

Something went wrong reading your downloaded{" "} {isDataset ? "datasets" : "models"}. Check that the backend is running and try again. @@ -187,7 +187,7 @@ export function InventoryErrorState({

-

+

{title}

-

+

{body}

diff --git a/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx b/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx index b821be4b0b..ade8d2de30 100644 --- a/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx +++ b/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx @@ -126,7 +126,7 @@ export function DatasetDownloadSection({ } >
- + {isDownloaded && } {!isDownloaded && isPartial && !downloading && ( diff --git a/studio/frontend/src/features/hub/catalog/dot-tag.tsx b/studio/frontend/src/features/hub/catalog/dot-tag.tsx index 9452a201d4..5ae1be53d0 100644 --- a/studio/frontend/src/features/hub/catalog/dot-tag.tsx +++ b/studio/frontend/src/features/hub/catalog/dot-tag.tsx @@ -36,7 +36,7 @@ export function DotTag({ return ( diff --git a/studio/frontend/src/features/hub/catalog/download-card.tsx b/studio/frontend/src/features/hub/catalog/download-card.tsx index 9b4bc5fd01..9bc64ced0e 100644 --- a/studio/frontend/src/features/hub/catalog/download-card.tsx +++ b/studio/frontend/src/features/hub/catalog/download-card.tsx @@ -140,7 +140,7 @@ export function CardUpdateButton({ e.stopPropagation(); onClick(); }} - className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-amber-500/[0.07] pl-2 pr-2.5 text-[12px] font-medium text-amber-800/90 transition-colors duration-150 hover:bg-amber-500/[0.12] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-500/25 dark:bg-amber-400/[0.08] dark:text-amber-200/85 dark:hover:bg-amber-400/[0.16]" + className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-amber-500/[0.07] pl-2 pr-2.5 text-[0.75rem] font-medium text-amber-800/90 transition-colors duration-150 hover:bg-amber-500/[0.12] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-500/25 dark:bg-amber-400/[0.08] dark:text-amber-200/85 dark:hover:bg-amber-400/[0.16]" > {pendingUrl && (
-

+

{hostOf(pendingUrl)}

-

+

{pendingUrl}

diff --git a/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx b/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx index 0d6878b687..9345874a53 100644 --- a/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx +++ b/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx @@ -128,7 +128,7 @@ const FIT_BADGE: Record = { /** Chip styling matching the on-device list's StatChip, no icon. */ const CHIP_BASE = - "inline-flex h-5 shrink-0 items-center justify-center whitespace-nowrap rounded-full border px-2 text-[11.5px] font-medium tabular-nums leading-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"; + "inline-flex h-5 shrink-0 items-center justify-center whitespace-nowrap rounded-full border px-2 text-[0.71875rem] font-medium tabular-nums leading-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"; const CHIP_DEFAULT = "border-foreground/15 bg-muted text-foreground/85 dark:border-border/60 dark:bg-white/[0.04] dark:text-foreground/85"; const CHIP_ACTIVE = @@ -184,7 +184,7 @@ function QuantBadge({ // group's `overflow-hidden` sacrifices the trailing status tags instead. @@ -914,7 +914,7 @@ export function GgufDownloadCard({ {/* Quant label + status tags travel together as one left-aligned group so the fit-info icon never floats orphaned from its tags; only the chevron pins right, the standard select affordance. */} - + {selected ? ( ) : ( - + Select quantization )} @@ -1126,7 +1126,7 @@ export function GgufDownloadCard({ diff --git a/studio/frontend/src/features/hub/catalog/gguf-status-cards.tsx b/studio/frontend/src/features/hub/catalog/gguf-status-cards.tsx index 6cc764b876..c7f402159b 100644 --- a/studio/frontend/src/features/hub/catalog/gguf-status-cards.tsx +++ b/studio/frontend/src/features/hub/catalog/gguf-status-cards.tsx @@ -34,7 +34,7 @@ export function GgufDownloadStatusCard({
@@ -91,7 +91,7 @@ export function GgufDownloadingFallbackCard({
- + {progress.variant && } Downloading… diff --git a/studio/frontend/src/features/hub/catalog/hub-detail-view.tsx b/studio/frontend/src/features/hub/catalog/hub-detail-view.tsx index d733049ec3..e7d176442a 100644 --- a/studio/frontend/src/features/hub/catalog/hub-detail-view.tsx +++ b/studio/frontend/src/features/hub/catalog/hub-detail-view.tsx @@ -69,7 +69,7 @@ export function HubDetailView({ ) : ( - + {content} )} @@ -325,11 +325,11 @@ function ModelStatusChips({ > This model may not be supported yet. {unslothSupport.reason && ( - + {unslothSupport.reason} )} - + Still downloadable to your Hugging Face cache. @@ -349,7 +349,7 @@ function ModelStatusChips({ > This device has no supported GPU or usable MLX, so only GGUF models can run here. - + Still downloadable to your Hugging Face cache. @@ -368,7 +368,7 @@ function ModelStatusChips({ className="tooltip-compact max-w-xs" > Estimated 4-bit memory load is around {vramInfo.est} GB. - + {vramDetail} @@ -502,10 +502,10 @@ export const ModelInspector = memo(function ModelInspector({
-

+

Select a {isDataset ? "dataset" : "model"}

-

+

{isDataset ? "Choose a dataset from the catalog to inspect its download state and details." : "Choose an item from the catalog to inspect its runtime fit, download state, and model card."} @@ -528,7 +528,7 @@ export const ModelInspector = memo(function ModelInspector({ model.downloadsAllTime != null ? ( <> Downloads (30 days) - + {formatCompact(model.downloadsAllTime)} all time @@ -582,11 +582,11 @@ export const ModelInspector = memo(function ModelInspector({

-

+

{model.title}

{model.hubRepoId && ( @@ -599,7 +599,7 @@ export const ModelInspector = memo(function ModelInspector({
)}
-
+
{model.owner} {model.owner.toLowerCase() === "unsloth" && ( {isDataset && ( - + Dataset )} {!isDataset && ( - + {selectionHiddenByFilters && ( -

+

Current selection is hidden by the active filters or search.

)} {metadataUnavailable && ( -

+

Couldn't load full details from Hugging Face. Some fields may be incomplete.

diff --git a/studio/frontend/src/features/hub/catalog/model-readme.tsx b/studio/frontend/src/features/hub/catalog/model-readme.tsx index 3d215473e2..42cc55372a 100644 --- a/studio/frontend/src/features/hub/catalog/model-readme.tsx +++ b/studio/frontend/src/features/hub/catalog/model-readme.tsx @@ -126,17 +126,17 @@ function prepareReadmeBody(markdown: string): string { } const PROSE = cn( - "max-w-none text-[13.5px] leading-[1.7] text-foreground/85", - "[&_h1]:text-[18px] [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:mt-2 [&_h1]:mb-3", - "[&_h2]:text-[15.5px] [&_h2]:font-semibold [&_h2]:tracking-tight [&_h2]:mt-5 [&_h2]:mb-2", - "[&_h3]:text-[14px] [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-1.5", + "max-w-none text-[0.84375rem] leading-[1.7] text-foreground/85", + "[&_h1]:text-[1.125rem] [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:mt-2 [&_h1]:mb-3", + "[&_h2]:text-[0.96875rem] [&_h2]:font-semibold [&_h2]:tracking-tight [&_h2]:mt-5 [&_h2]:mb-2", + "[&_h3]:text-[0.875rem] [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-1.5", "[&_p]:my-2.5 [&_ul]:my-2 [&_ol]:my-2 [&_li]:my-0.5", "[&_a]:text-primary [&_a:hover]:underline", - "[&_code]:rounded-md [&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:font-mono", - "[&_pre]:rounded-[12px] [&_pre]:border [&_pre]:border-border/60 [&_pre]:bg-muted/40 [&_pre]:p-3 [&_pre]:text-[12px] [&_pre]:overflow-x-auto", + "[&_code]:rounded-md [&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.75rem] [&_code]:font-mono", + "[&_pre]:rounded-[12px] [&_pre]:border [&_pre]:border-border/60 [&_pre]:bg-muted/40 [&_pre]:p-3 [&_pre]:text-[0.75rem] [&_pre]:overflow-x-auto", "[&_pre_code]:bg-transparent [&_pre_code]:p-0", "[&_blockquote]:border-l-2 [&_blockquote]:border-border/60 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground", - "[&_table]:my-3 [&_table]:text-[12.5px]", + "[&_table]:my-3 [&_table]:text-[0.78125rem]", "[&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold [&_th]:border-b [&_th]:border-border/60", "[&_td]:px-2 [&_td]:py-1.5 [&_td]:border-b [&_td]:border-border/40", "[&_img]:rounded-[10px] [&_img]:my-2 [&_img]:max-w-full", @@ -300,7 +300,7 @@ function ReadmePlaceholder({ aria-busy="true" aria-live="polite" > -
+
{message ?? `Loading ${kind === "dataset" ? "dataset" : "model"} card…`}
@@ -540,7 +540,7 @@ export function ModelReadme({ ? current.error : readmeUnavailableMessage(subject); return ( -

+

{errorMessage}

); @@ -548,7 +548,7 @@ export function ModelReadme({ if (!current.body) { return ( -

+

{readmeMissingMessage(subject)}

); diff --git a/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx b/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx index 8cf5fc491e..926c002a65 100644 --- a/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx +++ b/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx @@ -68,7 +68,7 @@ export function InventoryWarningRow({ onRetry: () => void; }) { return ( -
+
Some on-device sources couldn't be scanned. Showing available{" "} @@ -76,7 +76,7 @@ export function InventoryWarningRow({ @@ -444,7 +444,7 @@ export function DownloadedList({ <> {pinnedItems.length > 0 && ( <> -
+
{unpinnedItems.length > 0 && ( -
+
All {isDataset ? "datasets" : "models"}
)} diff --git a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx index 6d1dc20414..156bafba70 100644 --- a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx +++ b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx @@ -188,14 +188,14 @@ function CachedSizeChipLive({ ))} ) : ( - + {variantMessage} )} @@ -225,7 +225,7 @@ export function StatChip({ return ( @@ -482,7 +482,7 @@ export const DiscoverModelRow = memo(function DiscoverModelRow({
-

+

{row.repo}

-
+
{row.owner} {row.owner.toLowerCase() === "unsloth" && ( @@ -528,7 +528,7 @@ export const DiscoverModelRow = memo(function DiscoverModelRow({ /> )} - + {formatRelativeShort(row.result.updatedAt)}
@@ -666,7 +666,7 @@ export const InventoryRow = memo(function InventoryRow({ {paramLabel} )} {quantLabel && ( - + {quantLabel} )} @@ -716,7 +716,7 @@ export const InventoryRow = memo(function InventoryRow({ ) : null; const ownerLine = ( - + {subLabel} {subLabel.toLowerCase() === "unsloth" && (
- + {title} {compactMarkers}
- + {subLabel} {subLabel.toLowerCase() === "unsloth" && ( @@ -851,7 +851,7 @@ export const InventoryRow = memo(function InventoryRow({ )}
-
+
{row.kind === "cache" ? (
- + {title} {statusMarkers} @@ -915,11 +915,11 @@ export const InventoryRow = memo(function InventoryRow({ cachePath={row.cachePath} /> ) : trailing ? ( - + {trailing} ) : sourceLabel ? ( - + {sourceLabel} ) : null} diff --git a/studio/frontend/src/features/hub/catalog/models-header.tsx b/studio/frontend/src/features/hub/catalog/models-header.tsx index 9629fc0a10..1702ed6fca 100644 --- a/studio/frontend/src/features/hub/catalog/models-header.tsx +++ b/studio/frontend/src/features/hub/catalog/models-header.tsx @@ -91,7 +91,7 @@ export function ModelsHeader({ {activeCheckpoint && ( -
+