Merge main into feat/studio-parallel-slots-ui for PR #7447

Second pass, for the commits that landed after the previous merge (#7514,
#7520, #7530, #7543).

One conflict, in the validate preflight's load-kwargs call: main added
cache_type_kv and tensor_parallel to it while this branch changed n_parallel
to read the single resolved value. Keep both, so the preflight still sizes
from _n_parallel and carries main's new arguments.

test_active_generations.py and test_parallel_slots_per_load.py are 114 passed.
tests/studio is 1861 passed with the 8 pre-existing MLX hardware-dispatch
failures that main shows on this machine too.
This commit is contained in:
Daniel Han 2026-07-28 12:28:09 +00:00
commit 936ea4d072
38 changed files with 2839 additions and 416 deletions

View file

@ -133,6 +133,9 @@ jobs:
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build
run: npm run build

File diff suppressed because it is too large Load diff

View file

@ -90,9 +90,10 @@ _DENYLIST: frozenset[str] = frozenset().union(*_DENYLIST_GROUPS)
def _flag_name(token: str) -> Optional[str]:
"""Flag name for ``token``, or None if it isn't a flag.
Peels `--key=value` to `--key`, treats `-1`/`-0.5` as values (shorts
always start with a letter), and normalises attached `-np8` / `-np-1` /
`-np8x` to `-np`. Mirrors the CLI's `_expand_attached_np_short`.
Peels `--key=value` to `--key`, normalises long-option underscores like
llama.cpp, treats `-1`/`-0.5` as values (shorts always start with a letter),
and normalises attached `-np8` / `-np-1` / `-np8x` to `-np`. Mirrors the
CLI's `_expand_attached_np_short`.
"""
token = token.strip()
if not token.startswith("-") or token in {"-", "--"}:
@ -100,6 +101,8 @@ def _flag_name(token: str) -> Optional[str]:
if len(token) >= 2 and (token[1].isdigit() or token[1] == "."):
return None
name = token.split("=", 1)[0]
if name.startswith("--"):
name = name.replace("_", "-")
if len(name) > 3 and name.startswith("-np"):
suffix = name[3:]
if suffix[0].isdigit() or (

View file

@ -267,6 +267,8 @@ class ValidateModelRequest(BaseModel):
# /load; defaults preserve old behavior for callers that omit them.
max_seq_length: int = Field(0, ge = 0, le = 1048576)
load_in_4bit: bool = Field(True)
cache_type_kv: Optional[str] = Field(None)
tensor_parallel: bool = Field(False)
gpu_ids: Optional[List[int]] = Field(None)
gpu_memory_mode: Literal["auto", "manual"] = Field(
"auto",

View file

@ -1004,8 +1004,13 @@ try:
_DEFAULT_MAX_TOKENS_FLOOR,
_DEFAULT_STREAM_STALL_TIMEOUT_S,
_canonicalize_spec_mode,
_extra_args_n_ubatch,
_extra_args_set_spec_type,
_hf_offline_if_dns_dead,
_kv_bytes_per_elem,
_kv_unified_from_args,
_planned_main_cache_types,
_swa_full_from_args_or_env,
detect_reasoning_flags,
)
from core.inference.llama_server_args import (
@ -1043,8 +1048,13 @@ except ImportError:
_DEFAULT_MAX_TOKENS_FLOOR,
_DEFAULT_STREAM_STALL_TIMEOUT_S,
_canonicalize_spec_mode,
_extra_args_n_ubatch,
_extra_args_set_spec_type,
_hf_offline_if_dns_dead,
_kv_bytes_per_elem,
_kv_unified_from_args,
_planned_main_cache_types,
_swa_full_from_args_or_env,
detect_reasoning_flags,
)
from core.inference.llama_server_args import (
@ -3349,6 +3359,10 @@ def _request_matches_loaded_settings(
strip_offload = request.gpu_memory_mode == "manual",
)
)
if not llama_backend.is_diffusion and llama_backend.swa_full != _swa_full_from_args_or_env(
effective_extra
):
return False
if not _tensor_parallel_matches_loaded(
effective_extra, request.tensor_parallel, llama_backend.tensor_parallel
):
@ -4464,10 +4478,12 @@ def _estimate_gguf_kv_gb(
max_seq_length: int,
llama_extra_args: Optional[list[str]] = None,
n_parallel: int = 1,
cache_type_kv: Optional[str] = None,
tensor_parallel: bool = False,
) -> float:
"""KV-cache VRAM (GB) at the larger of max_seq_length and any `--ctx-size`/`-c`
override, over n_parallel slots, with the default f16 cache so the estimate is
never below what the server allocates. 0 if metadata is unreadable."""
override, over n_parallel slots, using the effective cache settings and managed
launcher defaults. 0 if metadata is unreadable."""
try:
from core.inference.llama_server_args import parse_ctx_override
@ -4482,7 +4498,43 @@ def _estimate_gguf_kv_gb(
ctx = max(max_seq_length or 0, ctx_override) or (probe._context_length or 0)
if ctx <= 0:
return 0.0
kv = probe._estimate_kv_cache_bytes(ctx, n_parallel = max(1, n_parallel or 1))
slots = max(1, n_parallel or 1)
managed_kv_unified = bool(
slots > 1
and LlamaCppBackend.probe_server_capabilities().get("supports_kv_unified", False)
)
planned_cache_types = _planned_main_cache_types(
cache_type_kv,
llama_extra_args,
)
if tensor_parallel and any(
cache_type not in LlamaCppBackend._TENSOR_PARALLEL_KV_TYPES
for cache_type in planned_cache_types
):
# Tensor mode strips quantized axes, but a layer fallback restores
# the original settings. Size for the larger successful outcome.
tensor_cache_types = _planned_main_cache_types(None, None)
cache_type_for_budget = max(
(*planned_cache_types, *tensor_cache_types, "f16"),
key = _kv_bytes_per_elem,
)
else:
cache_type_for_budget = max(
planned_cache_types,
key = _kv_bytes_per_elem,
)
kv = probe._estimate_kv_cache_bytes(
ctx,
cache_type_for_budget,
n_parallel = slots,
swa_full = _swa_full_from_args_or_env(llama_extra_args),
kv_unified = _kv_unified_from_args(
llama_extra_args,
default = managed_kv_unified,
),
n_ubatch = _extra_args_n_ubatch(llama_extra_args, n_ctx = ctx),
flash_attn = False,
)
return kv / (1024**3)
except Exception as e:
logger.warning(f"Could not size GGUF KV cache for training guard: {e}")
@ -4495,6 +4547,8 @@ def _estimate_gguf_required_gb(
max_seq_length: int = 0,
llama_extra_args: Optional[list[str]] = None,
n_parallel: int = 1,
cache_type_kv: Optional[str] = None,
tensor_parallel: bool = False,
) -> Optional[float]:
"""Approximate GGUF VRAM (GB): quantized weights + companions, plus the KV
cache for local files (unreadable pre-download for remote). None when nothing
@ -4510,7 +4564,12 @@ def _estimate_gguf_required_gb(
total_bytes += Path(f).stat().st_size
if total_bytes > 0:
return total_bytes / (1024**3) + _estimate_gguf_kv_gb(
main, max_seq_length, llama_extra_args, n_parallel
main,
max_seq_length,
llama_extra_args,
n_parallel,
cache_type_kv,
tensor_parallel,
)
repo = getattr(config, "gguf_hf_repo", None)
@ -4651,6 +4710,8 @@ def _guard_chat_load_against_training(
requested_gpu_ids: Optional[List[int]],
llama_extra_args: Optional[list[str]] = None,
n_parallel: int = 1,
cache_type_kv: Optional[str] = None,
tensor_parallel: bool = False,
gpu_memory_mode: Literal["auto", "manual"] = "auto",
) -> None:
"""Protect active training from automatically placed chat-model loads.
@ -4705,6 +4766,11 @@ def _guard_chat_load_against_training(
max_seq_length = max_seq_length,
llama_extra_args = llama_extra_args,
n_parallel = n_parallel,
cache_type_kv = cache_type_kv,
tensor_parallel = (
_effective_tensor_parallel(llama_extra_args, tensor_parallel)
and (is_vulkan or LlamaCppBackend._effective_gpu_count(requested_gpu_ids) >= 2)
),
)
if is_gguf
else None
@ -5460,6 +5526,8 @@ async def _load_model_impl(
requested_gpu_ids = effective_gpu_ids,
llama_extra_args = extra_llama_args,
n_parallel = _n_parallel,
cache_type_kv = request.cache_type_kv,
tensor_parallel = bool(request.tensor_parallel),
gpu_memory_mode = request.gpu_memory_mode,
)
@ -6141,6 +6209,8 @@ async def validate_model(
1,
)
),
cache_type_kv = request.cache_type_kv,
tensor_parallel = request.tensor_parallel,
gpu_memory_mode = request.gpu_memory_mode,
)

View file

@ -451,6 +451,9 @@ class TestChatLoadGuardRoute(unittest.TestCase):
decision,
gpu_memory_mode = "auto",
requested_gpu_ids = None,
llama_extra_args = None,
cache_type_kv = None,
tensor_parallel = False,
):
config = config or SimpleNamespace(is_gguf = False, is_lora = False, path = None)
with _stub_guard_deps(
@ -463,6 +466,9 @@ class TestChatLoadGuardRoute(unittest.TestCase):
load_in_4bit = True,
max_seq_length = 0,
requested_gpu_ids = requested_gpu_ids,
llama_extra_args = llama_extra_args,
cache_type_kv = cache_type_kv,
tensor_parallel = tensor_parallel,
gpu_memory_mode = gpu_memory_mode,
)
@ -597,6 +603,32 @@ class TestChatLoadGuardRoute(unittest.TestCase):
self.assertEqual(captured[0]["is_gguf"], True)
self.assertEqual(captured[0]["required_override_gb"], 12.5)
def test_vulkan_gguf_estimate_keeps_tensor_cache_coercion(self):
config = SimpleNamespace(is_gguf = True)
estimate_kwargs = {}
with (
patch.object(
self.route,
"_estimate_gguf_required_gb",
side_effect = lambda *args, **kwargs: estimate_kwargs.update(kwargs) or 12.5,
),
patch.object(
self.route.LlamaCppBackend,
"_effective_gpu_count",
return_value = 0,
),
patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True),
):
self._guard(
config = config,
training_active = True,
decision = (True, {}),
llama_extra_args = ["--split-mode", "tensor"],
cache_type_kv = "q4_0",
)
self.assertEqual(estimate_kwargs["cache_type_kv"], "q4_0")
self.assertTrue(estimate_kwargs["tensor_parallel"])
class TestEffectiveLoadIn4bit(unittest.TestCase):
@classmethod
@ -745,7 +777,12 @@ class TestValidateRefusesDuringTraining(unittest.TestCase):
# /load then 409s after the frontend has already unloaded.
from models.inference import ValidateModelRequest
request = ValidateModelRequest(model_path = "unsloth/Qwen3-1.7B", max_seq_length = 4096)
request = ValidateModelRequest(
model_path = "unsloth/Qwen3-1.7B",
max_seq_length = 4096,
cache_type_kv = "f32",
tensor_parallel = True,
)
cfg = SimpleNamespace(
identifier = "unsloth/Qwen3-1.7B",
display_name = "Qwen3-1.7B",
@ -774,6 +811,8 @@ class TestValidateRefusesDuringTraining(unittest.TestCase):
asyncio.run(self.route.validate_model(request, current_subject = "u"))
self.assertEqual(captured.get("llama_extra_args"), ["-c", "32768"])
self.assertIn("n_parallel", captured)
self.assertEqual(captured.get("cache_type_kv"), "f32")
self.assertTrue(captured.get("tensor_parallel"))
def test_metadata_probe_skips_training_guard(self):
# A header-only probe (include_context_length) allocates no VRAM, so the
@ -985,6 +1024,8 @@ class TestEstimateGgufRequiredGb(unittest.TestCase):
class _FakeBackend:
_context_length = 2048
_TENSOR_PARALLEL_KV_TYPES = frozenset({"f16", "bf16", "f32"})
supports_kv_unified = True
def _read_gguf_metadata(self, path):
pass
@ -992,13 +1033,27 @@ class TestEstimateGgufRequiredGb(unittest.TestCase):
def _can_estimate_kv(self):
return True
@classmethod
def probe_server_capabilities(cls):
return {"supports_kv_unified": cls.supports_kv_unified}
def _estimate_kv_cache_bytes(
self,
ctx,
cache_type = None,
n_parallel = 1,
swa_full = False,
kv_unified = False,
n_ubatch = None,
flash_attn = True,
):
seen["ctx"] = ctx
seen["cache_type"] = cache_type
seen["n_parallel"] = n_parallel
seen["swa_full"] = swa_full
seen["kv_unified"] = kv_unified
seen["n_ubatch"] = n_ubatch
seen["flash_attn"] = flash_attn
return ctx * n_parallel * (1024**2) # 1 MiB per ctx unit per slot
with patch.object(self.route, "LlamaCppBackend", _FakeBackend):
@ -1009,6 +1064,8 @@ class TestEstimateGgufRequiredGb(unittest.TestCase):
)
self.assertEqual(seen["ctx"], 131072)
self.assertEqual(seen["n_parallel"], 1) # default single slot
self.assertFalse(seen["swa_full"])
self.assertFalse(seen["flash_attn"])
# override below max_seq_length -> larger (max_seq_length) wins
self.assertAlmostEqual(r._estimate_gguf_kv_gb("m", 4096, ["--ctx-size", "1024"]), 4.0)
self.assertEqual(seen["ctx"], 4096)
@ -1020,6 +1077,50 @@ class TestEstimateGgufRequiredGb(unittest.TestCase):
# --parallel slots scale the cache the same way the launcher does
self.assertAlmostEqual(r._estimate_gguf_kv_gb("m", 4096, None, 4), 16.0)
self.assertEqual(seen["n_parallel"], 4)
self.assertTrue(seen["kv_unified"])
# User extras are appended after Studio's managed default.
r._estimate_gguf_kv_gb("m", 4096, ["--no-kv-unified"], 4)
self.assertFalse(seen["kv_unified"])
# An older binary without the flag keeps separate KV streams.
_FakeBackend.supports_kv_unified = False
r._estimate_gguf_kv_gb("m", 4096, None, 4)
self.assertFalse(seen["kv_unified"])
r._estimate_gguf_kv_gb("m", 4096, None, 1, "f32")
self.assertEqual(seen["cache_type"], "f32")
r._estimate_gguf_kv_gb("m", 4096, ["--cache-type-v", "f32"])
self.assertEqual(seen["cache_type"], "f32")
with patch.dict(self.route.os.environ, {"LLAMA_ARG_CACHE_TYPE_K": "f32"}):
r._estimate_gguf_kv_gb("m", 4096)
self.assertEqual(seen["cache_type"], "f32")
with patch.dict(
self.route.os.environ,
{
"LLAMA_ARG_CACHE_TYPE_K": "q4_0",
"LLAMA_ARG_CACHE_TYPE_V": "q4_0",
},
):
r._estimate_gguf_kv_gb("m", 4096)
self.assertEqual(seen["cache_type"], "q4_0")
r._estimate_gguf_kv_gb(
"m",
4096,
["--cache-type-k", "q4_0", "--cache-type-v", "q4_0"],
tensor_parallel = True,
)
self.assertEqual(seen["cache_type"], "f16")
r._estimate_gguf_kv_gb(
"m",
4096,
["--cache-type-k", "f32", "--cache-type-v", "q4_0"],
tensor_parallel = True,
)
self.assertEqual(seen["cache_type"], "f32")
# Full SWA mode follows the same pass-through args as the launcher.
r._estimate_gguf_kv_gb("m", 4096, ["--swa_full"])
self.assertTrue(seen["swa_full"])
r._estimate_gguf_kv_gb("m", 4096, ["--kv_unified", "--ubatch_size", "256"])
self.assertTrue(seen["kv_unified"])
self.assertEqual(seen["n_ubatch"], 256)
# ── load_model integration: authoritative 409, and no unload before refusal ──

View file

@ -183,11 +183,12 @@ def test_already_in_target_state_reloads_on_mode_change(loaded, requested):
assert _target_state(_loaded_backend(loaded), requested) is False
def test_already_in_target_state_ignores_mode_for_diffusion():
def test_already_in_target_state_ignores_mode_for_diffusion(monkeypatch):
# The diffusion runner is mode-agnostic (always "auto"), so a standing manual
# preference must not force a needless reload.
backend = _loaded_backend("auto")
backend._is_diffusion = True
monkeypatch.setenv("LLAMA_ARG_SWA_FULL", "1")
assert _target_state(backend, "manual") is True

View file

@ -76,6 +76,39 @@ from core.inference.llama_cpp import _CTX_FIT_VRAM_FRACTION, LlamaCppBackend
# Helpers
def _runtime_kv_cells(
n_ctx: int,
*,
slots: int = 1,
unified: bool = True,
) -> int:
"""Total KV cells allocated by llama.cpp across all streams."""
slots = max(1, slots)
padded_ctx = ((n_ctx + 255) // 256) * 256
streams = 1 if unified else slots
cells_per_stream = padded_ctx if unified else ((max(1, padded_ctx // slots) + 255) // 256) * 256
return cells_per_stream * streams
def _runtime_swa_cells(
n_ctx: int,
sliding_window: int,
*,
slots: int = 1,
unified: bool = True,
n_ubatch: int = 512,
) -> tuple[int, int]:
"""Return total non-SWA and compact-SWA cells allocated by llama.cpp."""
slots = max(1, slots)
streams = 1 if unified else slots
base_cells = _runtime_kv_cells(n_ctx, slots = slots, unified = unified)
cells_per_stream = base_cells // streams
swa_limit = sliding_window * (slots if unified else 1) + n_ubatch
swa_cells_per_stream = min(cells_per_stream, swa_limit)
swa_cells_per_stream = ((swa_cells_per_stream + 255) // 256) * 256
return base_cells, swa_cells_per_stream * streams
def _make_gguf_bytes(arch: str, kv_pairs: dict) -> bytes:
"""Build a minimal GGUF v3 blob with the given KV metadata.
@ -789,7 +822,7 @@ class TestMLAEstimation:
b = self._mla_backend()
result = b._estimate_kv_cache_bytes(1000, "f16")
# n_layers * ctx * 1 * key_len(576) * 2
expected = 61 * 1000 * 1 * 576 * 2
expected = 61 * _runtime_kv_cells(1000) * 1 * 576 * 2
assert result == expected
def test_mla_fallback_when_no_key_length(self):
@ -797,14 +830,14 @@ class TestMLAEstimation:
b = self._mla_backend(_kv_key_length = None)
# default _key_length_mla=192, so rope_dim=192
result = b._estimate_kv_cache_bytes(1000, "f16")
expected = 61 * 1000 * 1 * (512 + 192) * 2 # 704
expected = 61 * _runtime_kv_cells(1000) * 1 * (512 + 192) * 2 # 704
assert result == expected
def test_mla_fallback_no_key_length_mla(self):
"""No key_length and no key_length_mla: fall back to +64."""
b = self._mla_backend(_kv_key_length = None, _key_length_mla = None)
result = b._estimate_kv_cache_bytes(1000, "f16")
expected = 61 * 1000 * 1 * (512 + 64) * 2 # 576
expected = 61 * _runtime_kv_cells(1000) * 1 * (512 + 64) * 2 # 576
assert result == expected
def test_mla_defaults_n_kv_to_1_when_heads_absent(self):
@ -812,7 +845,7 @@ class TestMLAEstimation:
b = self._mla_backend(_n_kv_heads = None) # n_heads=128 still set
result = b._estimate_kv_cache_bytes(1000, "f16")
# Uses n_kv_mla=1, NOT n_heads=128
expected = 61 * 1000 * 1 * 576 * 2
expected = 61 * _runtime_kv_cells(1000) * 1 * 576 * 2
assert result == expected
def test_mla_q4_quantization(self):
@ -821,7 +854,7 @@ class TestMLAEstimation:
result_q4 = b._estimate_kv_cache_bytes(1000, "q4_0")
assert result_q4 < result_f16
# q4_0 bpe = 0.5625, f16 bpe = 2.0
assert result_q4 == int(61 * 1000 * 1 * 576 * 0.5625)
assert result_q4 == int(61 * _runtime_kv_cells(1000) * 1 * 576 * 0.5625)
# D. Path 2: Hybrid Mamba Estimation
@ -910,9 +943,8 @@ class TestSlidingWindowEstimation:
n_global = max(1, 62 // 4) # 15
n_swa = 62 - n_global # 47
kv_per = 16 * (128 + 128) * 2
# SWA cache is double-buffered: 2 * sliding_window cells, capped at n_ctx.
swa_cells = min(131072, 2 * 1024)
expected = int(n_global * 131072 * kv_per + n_swa * swa_cells * kv_per)
base_cells, swa_cells = _runtime_swa_cells(131072, 1024)
expected = int(n_global * base_cells * kv_per + n_swa * swa_cells * kv_per)
assert b._estimate_kv_cache_bytes(131072, "f16") == expected
def test_gpt_oss(self):
@ -929,8 +961,8 @@ class TestSlidingWindowEstimation:
n_global = max(1, 24 // 4) # 6
n_swa = 24 - n_global # 18
kv_per = 8 * (64 + 64) * 2
swa_cells = min(131072, 2 * 128)
expected = int(n_global * 131072 * kv_per + n_swa * swa_cells * kv_per)
base_cells, swa_cells = _runtime_swa_cells(131072, 128)
expected = int(n_global * base_cells * kv_per + n_swa * swa_cells * kv_per)
assert b._estimate_kv_cache_bytes(131072, "f16") == expected
def test_gemma4_per_layer_swa_metadata(self):
@ -952,21 +984,67 @@ class TestSlidingWindowEstimation:
sliding_layers = 25
def expected(ctx):
full = full_layers * ctx * 2 * (512 + 512) * 2
sliding = sliding_layers * min(ctx, 2 * 1024) * 8 * (256 + 256) * 2
base_cells, swa_cells = _runtime_swa_cells(ctx, 1024)
full = full_layers * base_cells * 2 * (512 + 512) * 2
sliding = sliding_layers * swa_cells * 8 * (256 + 256) * 2
return int(full + sliding)
for ctx in (4096, 46500, 262144):
assert b._estimate_kv_cache_bytes(ctx, "f16") == expected(ctx)
def test_gemma4_flash_attn_off_pads_v_to_model_max(self):
b = self._swa_backend(
_n_layers = 35,
_n_kv_heads = 1,
_n_heads = 8,
_embedding_length = 1536,
_kv_key_length = 512,
_kv_value_length = 512,
_sliding_window = 512,
_sliding_window_pattern = [True, True, True, True, False] * 7,
_kv_key_length_swa = 256,
_kv_value_length_swa = 256,
_shared_kv_layers = 20,
)
ctx = 5000
slots = 3
base_cells, swa_cells = _runtime_swa_cells(ctx, 512, slots = slots, unified = True)
max_v_width = 512
expected = (
3 * base_cells * (512 + max_v_width) * 2 + 12 * swa_cells * (256 + max_v_width) * 2
)
actual = b._estimate_kv_cache_bytes(
ctx,
"f16",
n_parallel = slots,
flash_attn = False,
)
assert actual == expected
assert actual == 66 * 1024**2
assert actual > b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = slots)
def test_flash_attn_off_prices_quantized_v_retry_as_f16(self):
b = self._swa_backend(
_n_layers = 2,
_n_kv_heads = None,
_n_kv_heads_by_layer = [8, 2],
_sliding_window_pattern = [True, False],
_kv_key_length_swa = 64,
_kv_value_length_swa = 64,
)
off = b._estimate_kv_cache_bytes(4096, "q4_0", flash_attn = False)
on = b._estimate_kv_cache_bytes(4096, "q4_0")
assert off > on
def test_ctx_smaller_than_window(self):
"""When ctx < 2 * sliding_window, SWA cache caps at ctx."""
"""When context is smaller than the compact allowance, SWA caps at context."""
b = self._swa_backend(_sliding_window = 8192)
n_global = max(1, 62 // 4) # 15
n_swa = 62 - n_global # 47
kv_per = 16 * (128 + 128) * 2
ctx = 4096
expected = int(n_global * ctx * kv_per + n_swa * min(ctx, 2 * 8192) * kv_per)
base_cells, swa_cells = _runtime_swa_cells(ctx, 8192)
expected = int(n_global * base_cells * kv_per + n_swa * swa_cells * kv_per)
assert b._estimate_kv_cache_bytes(ctx, "f16") == expected
def test_odd_layer_count(self):
@ -974,7 +1052,8 @@ class TestSlidingWindowEstimation:
n_global = max(1, 63 // 4) # 15
n_swa = 63 - n_global # 48
kv_per = 16 * (128 + 128) * 2
expected = int(n_global * 1000 * kv_per + n_swa * min(1000, 2 * 1024) * kv_per)
base_cells, swa_cells = _runtime_swa_cells(1000, 1024)
expected = int(n_global * base_cells * kv_per + n_swa * swa_cells * kv_per)
assert b._estimate_kv_cache_bytes(1000, "f16") == expected
@ -1086,8 +1165,7 @@ class TestPathPriority:
b._full_attention_interval = 4
b._sliding_window = 1024 # Would trigger SWA
# MLA: 61 * 1000 * 1 * 576 * 2
expected_mla = int(61 * 1000 * 1 * 576 * 2)
expected_mla = int(61 * _runtime_kv_cells(1000) * 1 * 576 * 2)
assert b._estimate_kv_cache_bytes(1000, "f16") == expected_mla
def test_hybrid_over_swa(self):
@ -1104,7 +1182,7 @@ class TestPathPriority:
b._sliding_window = 1024 # Would trigger SWA
n_attn = 64 // 4
expected_hybrid = int(n_attn * 1000 * 4 * (256 + 256) * 2)
expected_hybrid = int(n_attn * _runtime_kv_cells(1000) * 4 * (256 + 256) * 2)
assert b._estimate_kv_cache_bytes(1000, "f16") == expected_hybrid
def test_all_paths_produce_different_values(self):
@ -1192,7 +1270,7 @@ class TestQuantization:
b._kv_key_length = 64
b._kv_value_length = 64
result = b._estimate_kv_cache_bytes(1000, cache_type)
expected = int(10 * 1000 * 1 * (64 + 64) * expected_bpe)
expected = int(10 * _runtime_kv_cells(1000) * 1 * (64 + 64) * expected_bpe)
assert result == expected
@ -1221,7 +1299,7 @@ class TestEdgeCases:
b._kv_key_length = 64
b._kv_value_length = 64
result = b._estimate_kv_cache_bytes(1, "f16")
assert result == int(10 * 1 * 1 * (64 + 64) * 2)
assert result == int(10 * _runtime_kv_cells(1) * 1 * (64 + 64) * 2)
def test_very_large_context(self):
"""1M context should not overflow or crash."""
@ -1242,7 +1320,7 @@ class TestEdgeCases:
b._kv_key_length = 64
b._kv_value_length = 64
result = b._estimate_kv_cache_bytes(100, "f16")
expected = int(10 * 100 * 8 * (64 + 64) * 2)
expected = int(10 * _runtime_kv_cells(100) * 8 * (64 + 64) * 2)
assert result == expected
def test_both_heads_none_falls_to_one(self):
@ -1253,7 +1331,7 @@ class TestEdgeCases:
b._kv_key_length = 64
b._kv_value_length = 64
result = b._estimate_kv_cache_bytes(100, "f16")
expected = int(10 * 100 * 1 * (64 + 64) * 2)
expected = int(10 * _runtime_kv_cells(100) * 1 * (64 + 64) * 2)
assert result == expected
@ -1335,12 +1413,21 @@ class TestServerFlags:
assert with_cp_full == no_cp_full
assert with_cp > b._estimate_kv_cache_bytes(8192, "f16")
def test_compact_swa_includes_ubatch_headroom_and_padding(self):
b = self._swa_backend(_sliding_window = 128)
ctx = 8192
result = b._estimate_kv_cache_bytes(ctx, "f16", n_ubatch = 512)
per_token = 4 * (256 + 256) * 2
n_swa = sum(b._sliding_window_pattern)
n_global = b._n_layers - n_swa
expected = n_global * ctx * per_token + n_swa * 768 * per_token
assert result == expected
# ── --parallel + --kv-unified ──────────────────────────────────
# Verified against llama-server: non-SWA caches partition n_ctx across
# slots (total memory constant); only SWA layers scale with --parallel.
# --kv-unified is a no-op for memory math (kept for API forward-compat).
# non-unified streams. Compact SWA sizing depends on the stream layout.
def test_gqa_kv_constant_across_parallel(self):
def test_gqa_kv_constant_for_aligned_stream_divisions(self):
b = self._gqa_backend()
baseline = b._estimate_kv_cache_bytes(4096, "f16")
for slots in (1, 2, 4, 8):
@ -1359,7 +1446,7 @@ class TestServerFlags:
== baseline
)
def test_swa_path_scales_only_swa_portion(self):
def test_swa_path_matches_aligned_stream_layout(self):
b = self._swa_backend()
ctx = 8192
baseline = b._estimate_kv_cache_bytes(ctx, "f16")
@ -1367,27 +1454,27 @@ class TestServerFlags:
swa = b._sliding_window
per_token_global = 4 * (256 + 256) * 2 # n_kv * (k+v) * f16
per_token_swa = 4 * (256 + 256) * 2 # k_swa/val_swa fall back
per_slot_swa_cells = min(ctx, 2 * swa) # not clamped at parallel=1
base_cells, swa_cells = _runtime_swa_cells(ctx, swa)
global_bytes = sum(
ctx * per_token_global for f in b._sliding_window_pattern[: b._n_layers] if not f
base_cells * per_token_global for f in b._sliding_window_pattern[: b._n_layers] if not f
)
swa_bytes_per_slot = sum(
per_slot_swa_cells * per_token_swa
for f in b._sliding_window_pattern[: b._n_layers]
if f
swa_bytes = sum(
swa_cells * per_token_swa for f in b._sliding_window_pattern[: b._n_layers] if f
)
# Sanity: parallel=1 reproduces baseline exactly
assert global_bytes + swa_bytes_per_slot == baseline
# Only the SWA portion scales by parallel
assert global_bytes + swa_bytes == baseline
for slots in (1, 2, 3, 4):
scaled = b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = slots, kv_unified = False)
# SWA cells clamp to per_slot_ctx when ctx/slots < 2*swa
per_slot_ctx = max(1, ctx // slots)
cells = min(ctx, 2 * swa, per_slot_ctx)
swa_bps = sum(
cells * per_token_swa for f in b._sliding_window_pattern[: b._n_layers] if f
base_cells, swa_cells = _runtime_swa_cells(ctx, swa, slots = slots, unified = False)
expected_global = sum(
base_cells * per_token_global
for f in b._sliding_window_pattern[: b._n_layers]
if not f
)
assert scaled == global_bytes + slots * swa_bps
expected_swa = sum(
swa_cells * per_token_swa for f in b._sliding_window_pattern[: b._n_layers] if f
)
assert scaled == expected_global + expected_swa
def test_mla_kv_constant_across_parallel(self):
b = LlamaCppBackend()
@ -1444,19 +1531,17 @@ class TestServerFlags:
ctx = 8192
swa = b._sliding_window
per_token = 4 * (256 + 256) * 2
global_bytes = sum(
ctx * per_token for f in b._sliding_window_pattern[: b._n_layers] if not f
)
n_swa_layers = sum(1 for f in b._sliding_window_pattern[: b._n_layers] if f)
slots = 3
per_slot_ctx = max(1, ctx // slots)
swa_cells = min(ctx, 2 * swa, per_slot_ctx)
swa_bytes_per_slot = n_swa_layers * swa_cells * per_token
base_cells, swa_cells = _runtime_swa_cells(ctx, swa, slots = slots, unified = False)
n_global_layers = b._n_layers - n_swa_layers
global_bytes = n_global_layers * base_cells * per_token
swa_bytes = n_swa_layers * swa_cells * per_token
cp_extra_per_slot = n_swa_layers * 4 * swa * per_token # 4 checkpoints
flagged = b._estimate_kv_cache_bytes(
ctx, "f16", ctx_checkpoints = 4, n_parallel = slots, kv_unified = False
)
assert flagged == global_bytes + slots * (swa_bytes_per_slot + cp_extra_per_slot)
assert flagged == global_bytes + swa_bytes + slots * cp_extra_per_slot
# ── --kv-offload (kv_on_gpu) ───────────────────────────────────
@ -1535,22 +1620,40 @@ class TestServerFlags:
assert fitted_default == ctx
assert fitted_full < ctx
def test_tensor_planner_threads_swa_full_through_estimator(self):
b = self._swa_backend()
estimate = b._estimate_kv_cache_bytes
calls = []
def record(*args, **kwargs):
calls.append(kwargs)
return estimate(*args, **kwargs)
b._estimate_kv_cache_bytes = record
b._plan_tensor_parallel(
[(0, 32768), (1, 32768)],
1024**3,
8192,
cache_type_kv = "f16",
swa_full = True,
flash_attn = False,
)
assert calls
assert all(call["swa_full"] is True for call in calls)
assert all(call["flash_attn"] is False for call in calls)
# J2.5. --parallel N memory accounting (per-layer-type scaling rule)
class TestParallelSWAScaling:
"""Per-layer-type scaling rule vs the closed form measured from
llama-server. Empirical formula on Gemma-3 270m at ctx=8192:
total_kv = 24 + parallel * 15 (MiB).
"""Per-layer-type scaling rule measured from llama-server.
Rule (verified vs ``llama-server`` log on real GGUFs):
* non-SWA layers: total cells = n_ctx, partitioned across slots,
memory CONSTANT in n_parallel.
* SWA layers: per-slot cells = 2 * sliding_window (clamped at
n_ctx and at per_slot_ctx); memory LINEAR in n_parallel.
* --kv-unified is a no-op for memory math; both modes give the
same total in measured cases.
* non-SWA layers use the padded per-stream context.
* compact SWA adds ubatch headroom and pads to 256 cells.
* unified mode uses one stream with all slot windows.
* non-unified mode allocates one stream per slot.
"""
def _gqa_backend(self, **overrides):
@ -1586,7 +1689,7 @@ class TestParallelSWAScaling:
setattr(b, k, v)
return b
# ── non-SWA paths: constant ────────────────────────────────────
# ── non-SWA paths: constant when stream divisions are aligned ──
def test_pure_gqa_constant_across_parallel(self):
b = self._gqa_backend()
@ -1633,25 +1736,53 @@ class TestParallelSWAScaling:
for slots in (1, 2, 4, 8):
assert b._estimate_kv_cache_bytes(8192, "f16", n_parallel = slots) == baseline
# ── SWA paths: scale only the SWA portion ──────────────────────
def test_non_swa_paths_follow_unaligned_stream_padding(self):
mla = LlamaCppBackend()
mla._n_layers = 60
mla._n_kv_heads = 1
mla._kv_lora_rank = 512
mla._key_length_mla = 64
mla._kv_key_length = 576
def test_swa_pattern_scales_only_swa_portion(self):
hybrid = LlamaCppBackend()
hybrid._n_layers = 64
hybrid._n_kv_heads = 16
hybrid._n_heads = 32
hybrid._embedding_length = 4096
hybrid._kv_key_length = 128
hybrid._kv_value_length = 128
hybrid._ssm_inner_size = 4096
hybrid._full_attention_interval = 4
legacy = LlamaCppBackend()
legacy._n_layers = 32
legacy._n_kv_heads = 8
legacy._n_heads = 8
legacy._embedding_length = 4096
for backend in (self._gqa_backend(), mla, hybrid, legacy):
bytes_per_cell = backend._estimate_kv_cache_bytes(256, "f16") // 256
unified = backend._estimate_kv_cache_bytes(5000, "f16", n_parallel = 3, kv_unified = True)
separate = backend._estimate_kv_cache_bytes(5000, "f16", n_parallel = 3, kv_unified = False)
assert unified == 5120 * bytes_per_cell
assert separate == 5376 * bytes_per_cell
# ── SWA paths: aligned stream scaling ──────────────────────────
def test_swa_pattern_matches_aligned_stream_layout(self):
b = self._swa_backend()
ctx = 8192
swa = b._sliding_window
per_token = 1 * (256 + 256) * 2 # n_kv * (k+v) * f16
n_global = sum(1 for f in b._sliding_window_pattern if not f)
n_swa = sum(1 for f in b._sliding_window_pattern if f)
global_bytes = n_global * ctx * per_token
for slots in (1, 2, 4, 8):
per_slot_ctx = max(1, ctx // slots)
cells = min(ctx, 2 * swa, per_slot_ctx)
swa_bps = n_swa * cells * per_token
for unified in (True, False):
base_cells, swa_cells = _runtime_swa_cells(ctx, swa, slots = slots, unified = unified)
got = b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = slots, kv_unified = unified)
assert got == global_bytes + slots * swa_bps
assert got == (n_global * base_cells * per_token + n_swa * swa_cells * per_token)
def test_swa_fallback_scales_only_swa_portion(self):
def test_swa_fallback_matches_aligned_stream_layout(self):
# No per-layer pattern -> 1/4-global heuristic.
b = self._swa_backend(_sliding_window_pattern = None)
ctx = 8192
@ -1660,34 +1791,28 @@ class TestParallelSWAScaling:
n_global = max(1, n_layers // 4)
n_swa = n_layers - n_global
per_token = 1 * (256 + 256) * 2
global_bytes = n_global * ctx * per_token
for slots in (1, 2, 4, 8):
per_slot_ctx = max(1, ctx // slots)
cells = min(ctx, 2 * swa, per_slot_ctx)
swa_bps = n_swa * cells * per_token
got = b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = slots)
assert got == global_bytes + slots * swa_bps
for unified in (True, False):
base_cells, swa_cells = _runtime_swa_cells(ctx, swa, slots = slots, unified = unified)
got = b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = slots, kv_unified = unified)
assert got == (n_global * base_cells * per_token + n_swa * swa_cells * per_token)
def test_swa_per_slot_clamped_when_ctx_lt_slots_x_2window(self):
# ctx=4096 / slots=8 -> per_slot_ctx=512, but 2*sliding=1024.
# SWA cells clamp at per_slot_ctx (512), not 2*sliding.
# ctx=4096 / slots=8 gives a 512-cell stream, which caps compact SWA.
b = self._swa_backend()
ctx = 4096
per_slot_ctx_at_8 = ctx // 8
assert per_slot_ctx_at_8 < 2 * b._sliding_window
# Build expected with the clamped formula
n_swa = sum(1 for f in b._sliding_window_pattern if f)
n_global = sum(1 for f in b._sliding_window_pattern if not f)
per_token = 1 * (256 + 256) * 2
global_bytes = n_global * ctx * per_token
cells = min(ctx, 2 * b._sliding_window, per_slot_ctx_at_8)
assert cells == per_slot_ctx_at_8
expected = global_bytes + 8 * (n_swa * cells * per_token)
assert b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = 8) == expected
base_cells, swa_cells = _runtime_swa_cells(ctx, b._sliding_window, slots = 8, unified = False)
assert swa_cells == 8 * per_slot_ctx_at_8
expected = n_global * base_cells * per_token + n_swa * swa_cells * per_token
assert b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = 8, kv_unified = False) == expected
def test_swa_full_does_not_scale_under_parallel(self):
# swa_full forces every layer to n_ctx -> all-global GQA-style
# total, constant in parallel.
def test_swa_full_constant_for_aligned_stream_divisions(self):
# swa_full forces every layer to n_ctx. This aligned context remains
# constant across the tested stream divisions.
b = self._swa_backend()
ctx = 8192
baseline = b._estimate_kv_cache_bytes(ctx, "f16", swa_full = True)
@ -1696,25 +1821,32 @@ class TestParallelSWAScaling:
b._estimate_kv_cache_bytes(ctx, "f16", swa_full = True, n_parallel = slots) == baseline
)
# ── kv_unified: no-op for memory math ──────────────────────────
# ── kv_unified stream layout ────────────────────────────────────
def test_kv_unified_is_no_op_for_memory_math(self):
# unified=True and unified=False must give the same total bytes
# for every backend type and parallel value.
backends = [
("gqa", self._gqa_backend()),
("swa", self._swa_backend()),
]
for label, b in backends:
for slots in (1, 2, 4, 8):
u = b._estimate_kv_cache_bytes(8192, "f16", n_parallel = slots, kv_unified = True)
nu = b._estimate_kv_cache_bytes(8192, "f16", n_parallel = slots, kv_unified = False)
assert u == nu, f"{label} parallel={slots} unified-mismatch"
def test_kv_unified_changes_only_compact_swa_for_aligned_context(self):
gqa = self._gqa_backend()
swa = self._swa_backend()
for slots in (1, 2, 4, 8):
gqa_unified = gqa._estimate_kv_cache_bytes(
8192, "f16", n_parallel = slots, kv_unified = True
)
gqa_separate = gqa._estimate_kv_cache_bytes(
8192, "f16", n_parallel = slots, kv_unified = False
)
assert gqa_unified == gqa_separate
swa_unified = swa._estimate_kv_cache_bytes(
8192, "f16", n_parallel = slots, kv_unified = True
)
swa_separate = swa._estimate_kv_cache_bytes(
8192, "f16", n_parallel = slots, kv_unified = False
)
assert (swa_unified == swa_separate) is (slots == 1)
# ── Empirical Gemma-3 270m formula ─────────────────────────────
def test_matches_empirical_gemma3_270m_formula(self):
"""Exact match against the formula measured from llama-server:
"""Exact match against the non-unified formula measured from llama-server:
total_kv = 24 + parallel * 15 (MiB) at ctx=8192.
Geometry: 18 layers (3 global + 15 SWA), n_kv=1, head_dim=256,
@ -1736,12 +1868,16 @@ class TestParallelSWAScaling:
# Confirm pattern shape
assert sum(b._sliding_window_pattern) == n_swa
for slots, expected_mib in [(1, 39), (2, 54), (4, 84)]:
got_bytes = b._estimate_kv_cache_bytes(8192, "f16", n_parallel = slots)
got_bytes = b._estimate_kv_cache_bytes(8192, "f16", n_parallel = slots, kv_unified = False)
got_mib = got_bytes / (1024 * 1024)
assert (
got_mib == expected_mib
), f"slots={slots}: got {got_mib} MiB, expected {expected_mib} MiB"
for slots, expected_mib in [(1, 39), (2, 46.5), (4, 61.5)]:
got_bytes = b._estimate_kv_cache_bytes(8192, "f16", n_parallel = slots, kv_unified = True)
assert got_bytes / (1024 * 1024) == expected_mib
# J3. shared_kv_layers (Gemma 3n / Gemma 4)
@ -1844,8 +1980,8 @@ class TestSharedKVLayers:
assert sliding_in_unshared == 16
assert full_in_unshared == 4
kv_per = 4 * (256 + 256) * 2
swa_cells = min(ctx, 2 * 1024)
expected = full_in_unshared * ctx * kv_per + sliding_in_unshared * swa_cells * kv_per
base_cells, swa_cells = _runtime_swa_cells(ctx, 1024)
expected = full_in_unshared * base_cells * kv_per + sliding_in_unshared * swa_cells * kv_per
assert b._estimate_kv_cache_bytes(ctx, "f16") == expected
def test_shared_layers_reduces_estimate(self):
@ -1875,8 +2011,8 @@ class TestSharedKVLayers:
n_global = max(1, n_layers_kv // 4) # 5
n_swa = n_layers_kv - n_global # 15
kv_per = 4 * (256 + 256) * 2
swa_cells = min(ctx, 2 * 1024)
expected = n_global * ctx * kv_per + n_swa * swa_cells * kv_per
base_cells, swa_cells = _runtime_swa_cells(ctx, 1024)
expected = n_global * base_cells * kv_per + n_swa * swa_cells * kv_per
assert b._estimate_kv_cache_bytes(ctx, "f16") == expected
def test_shared_floors_at_one_layer(self):
@ -1896,13 +2032,12 @@ class TestSharedKVLayers:
unshared_pattern = b._sliding_window_pattern[:20] # 35 - 15 shared
sliding_in_unshared = sum(unshared_pattern)
global_in_unshared = len(unshared_pattern) - sliding_in_unshared
global_bytes = global_in_unshared * ctx * per_token
slots = 3
per_slot_ctx = max(1, ctx // slots)
swa_cells = min(ctx, 2 * swa, per_slot_ctx)
swa_bytes_per_slot = sliding_in_unshared * swa_cells * per_token
base_cells, swa_cells = _runtime_swa_cells(ctx, swa, slots = slots, unified = False)
global_bytes = global_in_unshared * base_cells * per_token
swa_bytes = sliding_in_unshared * swa_cells * per_token
flagged = b._estimate_kv_cache_bytes(ctx, "f16", n_parallel = slots, kv_unified = False)
assert flagged == global_bytes + slots * swa_bytes_per_slot
assert flagged == global_bytes + swa_bytes
def test_composes_with_ctx_checkpoints(self):
b = self._gemma3n_backend()
@ -2036,14 +2171,14 @@ class TestLifecycle:
)
assert b._can_estimate_kv()
result = b._estimate_kv_cache_bytes(131072, "f16")
# gemma3 -> period 6 from bootstrap; SWA cache double-buffered to
# 2 * sliding_window cells.
# gemma3 uses period 6 from the bootstrap resolver.
period = 6
kv_per = 16 * 256 * 2
base_cells, swa_cells = _runtime_swa_cells(131072, 1024)
expected = 0
for i in range(62):
is_swa = (i + 1) % period != 0
layer_ctx = min(131072, 2 * 1024) if is_swa else 131072
layer_ctx = swa_cells if is_swa else base_cells
expected += layer_ctx * kv_per
assert result == expected

View file

@ -221,6 +221,18 @@ class TestFlashAttnOff:
assert _flash_off(["llama-server", "-fa", "auto"]) == ["llama-server", "-fa", "off"]
assert _flash_off(["llama-server", "-fa=on"]) == ["llama-server", "-fa=off"]
@pytest.mark.parametrize("value", ["on", "enabled", "true", "1", "auto", "-1"])
def test_flips_every_enabled_value(self, value):
assert _flash_off(["llama-server", "--flash-attn", value]) == [
"llama-server",
"--flash-attn",
"off",
]
@pytest.mark.parametrize("value", ["off", "disabled", "false", "0"])
def test_none_for_every_disabled_value(self, value):
assert _flash_off(["llama-server", "--flash-attn", value]) is None
def test_flips_every_occurrence_last_wins(self):
# extra_args can re-enable FA after Unsloth's flag; llama.cpp is last-wins,
# so one leftover 'on' would re-crash the retry. Every enable must flip.
@ -384,6 +396,10 @@ class TestFlashAttnOffQuantizedKvCache:
out = _flash_off(["llama-server", "--flash-attn=on", "--cache_type_v=q8_0"])
assert out == ["llama-server", "--flash-attn=off", "--cache_type_v=f16"]
def test_underscore_alias_flash_attn_is_disabled(self):
out = _flash_off(["llama-server", "--flash_attn=on"])
assert out == ["llama-server", "--flash_attn=off"]
def test_underscore_value_not_normalized_for_nonquantized(self):
# Only the flag name is canonicalized; a non-quantized type value is
# matched verbatim and left untouched (no spurious reset).

View file

@ -63,7 +63,9 @@ from core.inference.llama_cpp import (
_extra_args_set_any_flag,
_extra_args_set_spec_type,
_is_mtp_model_name,
_kv_unified_from_args,
_mla_mtp_auto_enabled,
_swa_full_from_args_or_env,
)
@ -147,6 +149,41 @@ def test_is_mtp_model_name_handles_none():
assert _is_mtp_model_name("", "") is False
@pytest.mark.parametrize("flag", ["--swa-full", "--swa_full"])
def test_swa_full_detects_llama_cpp_long_flag_spellings(flag):
assert _swa_full_from_args_or_env([flag], {}) is True
@pytest.mark.parametrize("value", ["on", "enabled", "true", "1"])
def test_swa_full_detects_llama_cpp_env_truth_values(value):
assert _swa_full_from_args_or_env([], {"LLAMA_ARG_SWA_FULL": value}) is True
@pytest.mark.parametrize("value", ["", "off", "yes", "TRUE", " true ", "0"])
def test_swa_full_rejects_values_llama_cpp_treats_as_false(value):
assert _swa_full_from_args_or_env([], {"LLAMA_ARG_SWA_FULL": value}) is False
def test_swa_full_cli_wins_when_env_is_false():
assert _swa_full_from_args_or_env(["--swa-full"], {"LLAMA_ARG_SWA_FULL": "0"}) is True
@pytest.mark.parametrize("flag", ["--kv-unified", "--kv_unified", "-kvu"])
def test_kv_unified_detects_enable_aliases(flag):
assert _kv_unified_from_args([flag]) is True
@pytest.mark.parametrize("flag", ["--no-kv-unified", "--no_kv_unified", "-no-kvu"])
def test_kv_unified_detects_disable_aliases(flag):
assert _kv_unified_from_args(["--kv-unified", flag]) is False
def test_kv_unified_uses_environment_before_cli():
assert _kv_unified_from_args([], env = {"LLAMA_ARG_KV_UNIFIED": "true"}) is True
assert _kv_unified_from_args([], default = True, env = {"LLAMA_ARG_KV_UNIFIED": "false"}) is True
assert _kv_unified_from_args(["--kv-unified"], env = {"LLAMA_ARG_KV_UNIFIED": "false"}) is True
def test_is_mtp_model_name_detects_marker_in_filename(tmp_path):
gguf = tmp_path / "Qwen3.6-27B-MTP-Q4_K_M.gguf"
gguf.write_bytes(b"")

View file

@ -104,6 +104,9 @@ def _make_backend(effective_ctx = 98304, port = 51234):
inst._port = port
inst._effective_context_length = effective_ctx
inst._context_length = 262144
inst._effective_parallel_slots = 1
inst._kv_cache_unified = False
inst._kv_cache_context_total = None
return inst
@ -173,6 +176,31 @@ def test_fit_shrunk_ctx_overwrites_advertised_value(monkeypatch):
assert inst.context_length == 67584
def test_props_keeps_total_cache_context_for_slot_preflight(monkeypatch):
inst = _make_backend(effective_ctx = 32768)
inst._effective_parallel_slots = 4
_stub_props(
monkeypatch,
body = {"default_generation_settings": {"n_ctx": 8192}},
)
inst._reconcile_effective_ctx_with_server()
assert inst._effective_context_length == 8192
assert inst._kv_cache_context_total == 32768
def test_props_does_not_multiply_unified_cache_context(monkeypatch):
inst = _make_backend(effective_ctx = 32768)
inst._effective_parallel_slots = 4
inst._kv_cache_unified = True
_stub_props(
monkeypatch,
body = {"default_generation_settings": {"n_ctx": 32768}},
)
inst._reconcile_effective_ctx_with_server()
assert inst._effective_context_length == 32768
assert inst._kv_cache_context_total == 32768
def test_matching_ctx_is_left_alone(monkeypatch):
inst = _make_backend(effective_ctx = 98304)
_stub_props(

View file

@ -221,6 +221,34 @@ def test_fingerprint_tracks_effective_context_length(tmp_path):
assert backend._slot_launch_fingerprint() != before
def test_fingerprint_tracks_swa_full_mode(tmp_path):
backend = _resume_backend(tmp_path)
before = backend._slot_launch_fingerprint()
backend._swa_full = True
assert backend._slot_launch_fingerprint() != before
def test_fingerprint_tracks_unified_cache_mode(tmp_path):
backend = _resume_backend(tmp_path)
before = backend._slot_launch_fingerprint()
backend._kv_cache_unified = True
assert backend._slot_launch_fingerprint() != before
def test_fingerprint_tracks_flash_attention_mode(tmp_path):
backend = _resume_backend(tmp_path)
before = backend._slot_launch_fingerprint()
backend._flash_attn_enabled = False
assert backend._slot_launch_fingerprint() != before
def test_fingerprint_tracks_effective_cache_types(tmp_path):
backend = _resume_backend(tmp_path)
before = backend._slot_launch_fingerprint()
backend._effective_cache_types = ("f32", "f16")
assert backend._slot_launch_fingerprint() != before
def test_gguf_file_identity_covers_split_shards(tmp_path):
backend = _resume_backend(tmp_path)
first = tmp_path / "m-00001-of-00002.gguf"
@ -444,6 +472,81 @@ def test_save_skipped_when_estimate_exceeds_cap(monkeypatch, tmp_path):
assert backend.save_slots_for_resume() is None
def test_save_estimate_uses_total_context_and_active_cache_settings(monkeypatch, tmp_path):
backend = _resume_backend(tmp_path, n_slots = 4)
backend._effective_context_length = 8192
backend._kv_cache_context_total = 32768
backend._sliding_window = 4096
backend._swa_full = True
backend._flash_attn_enabled = False
backend._effective_cache_types = ("f32", "f16")
calls = []
def estimate(ctx, cache_type, **kwargs):
calls.append((ctx, cache_type, kwargs))
return 0
backend._estimate_kv_cache_bytes = estimate
_fake_disk(monkeypatch)
monkeypatch.setattr(
llama_cpp.httpx,
"post",
lambda *a, **k: _Resp(200, {"n_saved": 1, "n_written": 1}),
raising = False,
)
assert backend.save_slots_for_resume() is not None
assert calls == [
(
32768,
"f32",
{
"n_parallel": 4,
"swa_full": True,
"kv_unified": False,
"n_ubatch": 512,
"flash_attn": False,
},
)
]
def test_compact_swa_slot_save_is_skipped(monkeypatch, tmp_path):
backend = _resume_backend(tmp_path)
backend._sliding_window = 4096
backend._kv_key_length = 256
backend._kv_value_length = 256
backend._swa_full = False
backend._estimate_kv_cache_bytes = lambda *a, **k: (_ for _ in ()).throw(AssertionError)
monkeypatch.setattr(
llama_cpp.httpx,
"post",
lambda *a, **k: (_ for _ in ()).throw(AssertionError),
raising = False,
)
assert backend.save_slots_for_resume() is None
def test_window_without_kv_dims_still_saves(monkeypatch, tmp_path):
# phi3 reports a window but no key/value length, and llama.cpp runs it
# non-SWA, so the compact-SWA skip must not catch it.
backend = _resume_backend(tmp_path)
backend._sliding_window = 262144
backend._kv_key_length = None
backend._kv_value_length = None
backend._swa_full = False
posted = []
monkeypatch.setattr(
llama_cpp.httpx,
"post",
lambda *a, **k: posted.append(a)
or SimpleNamespace(status_code = 200, json = lambda: {"filename": "slot.bin"}),
raising = False,
)
backend.save_slots_for_resume()
assert posted
def test_save_skipped_when_model_file_changed_since_load(monkeypatch, tmp_path):
# The GGUF/sidecars were swapped on disk after the server loaded them, so the
# live KV belongs to the old weights: refuse to persist it (no POST at all).

View file

@ -602,7 +602,7 @@ def test_consumed_tool_final_pass_emits_latest_reasoning_summary(monkeypatch):
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
_patch_monotonic(monkeypatch, [200.0, 201.0, 203.0, 300.0, 400.0, 405.0, 405.0])
_patch_monotonic(monkeypatch, [200.0, 201.0, 203.0, 300.0, 400.0, 405.0, 410.0])
def fake_execute_tool(name, arguments, **_kwargs):
return "Rendered HTML canvas: Done."
@ -1495,6 +1495,7 @@ def test_forced_reprompt_plain_final_answer_is_visible(monkeypatch):
streams = [
[_sse({"content": "I will use render_html now."}), _done()],
[
_sse({"reasoning_content": "I reconsidered the request."}),
_sse({"content": "No tool is needed. Final answer: use a red square."}),
_done(),
],
@ -1531,8 +1532,19 @@ def test_forced_reprompt_plain_final_answer_is_visible(monkeypatch):
content_texts = [event.get("text", "") for event in events if event.get("type") == "content"]
assert content_texts == [
"I will use render_html now.",
"No tool is needed. Final answer: use a red square.",
(
"<think>I reconsidered the request.</think>"
"No tool is needed. Final answer: use a red square."
),
]
summaries = [event for event in events if event.get("type") == "reasoning_summary"]
assert len(summaries) == 1
visible_answer_index = next(
index
for index, event in enumerate(events)
if event.get("type") == "content" and "No tool is needed" in event.get("text", "")
)
assert visible_answer_index < events.index(summaries[0])
assert len(payloads) == 2
@ -1774,24 +1786,14 @@ def test_reprompted_tool_call_still_streams_final_answer(monkeypatch):
streams = [
[_sse({"content": "I will use render_html now."}), _done()],
[
_sse({"reasoning_content": "I should render the requested HTML."}),
_sse(
{
"tool_calls": [
{
"index": 0,
"id": "call_forced",
"type": "function",
"function": {
"name": "render_html",
"arguments": json.dumps(
{
"code": "<html><body>forced</body></html>",
"title": "Forced",
}
),
},
}
]
"content": (
'<tool_call>{"name":"render_html","arguments":'
'{"code":"<html><body>forced</body></html>",'
'"title":"Forced"}}</tool_call>'
)
}
),
_done(),
@ -1835,6 +1837,7 @@ def test_reprompted_tool_call_still_streams_final_answer(monkeypatch):
assert len(calls) == 1
content_texts = [event.get("text", "") for event in events if event.get("type") == "content"]
assert content_texts == ["I will use render_html now.", "Final note after tool."]
assert not any(event.get("type") == "reasoning_summary" for event in events)
assert len(payloads) == 3

View file

@ -113,6 +113,11 @@ def test_value_with_equals_form_passes_through():
assert validate_extra_args(["--top-k=20"]) == ["--top-k=20"]
def test_managed_long_flag_underscore_alias_is_rejected():
with pytest.raises(ValueError, match = "slot-save-path"):
validate_extra_args(["--slot_save_path", "/tmp/slots"])
def test_non_flag_token_passes_through():
# Bare positionals are passed through; llama-server can reject them.
assert validate_extra_args(["foo"]) == ["foo"]

View file

@ -76,7 +76,9 @@ from core.inference.llama_cpp import ( # noqa: E402
_extra_args_spec_draft_n_max,
_effective_tensor_parallel,
_env_main_cache_type_for_budget,
_effective_main_cache_types,
_extra_args_main_cache_type_for_budget,
_flash_attn_enabled_from_args,
_kv_bytes_per_elem,
_tensor_parallel_matches_loaded,
)
@ -132,6 +134,7 @@ class _StubDrafter:
def __init__(self, kv_per_token):
self._kv_per_token = kv_per_token
self._architecture = "gemma3"
def _can_estimate_kv(self):
return True
@ -177,6 +180,14 @@ class TestEmbeddedDraftKv:
two = _make_backend(nextn = 2)._mtp_draft_kv_bytes(65536)
assert two == pytest.approx(2 * one)
def test_unaligned_context_follows_runtime_stream_padding(self):
b = _make_backend()
bytes_per_cell = b._mtp_draft_kv_bytes(256) // 256
unified = b._mtp_draft_kv_bytes(5000, n_parallel = 3, kv_unified = True)
separate = b._mtp_draft_kv_bytes(5000, n_parallel = 3, kv_unified = False)
assert unified == 5120 * bytes_per_cell
assert separate == 5376 * bytes_per_cell
def test_embedded_draft_kv_floored_at_f16(self):
# The embedded MTP head is one layer, so llama.cpp's quantized-KV
# overhead is not amortized: a quantized draft KV fits LESS context than
@ -201,6 +212,15 @@ class TestEmbeddedDraftKv:
both_f16 = b._mtp_draft_kv_bytes(131072, draft_cache_type_k = "f16", draft_cache_type_v = "f16")
assert both_q4 == k_only == both_f16 # floored at f16, never under-reserved
def test_flash_attn_off_uses_model_wide_v_width(self):
b = _make_backend(n_layers = 2)
b._n_kv_heads_by_layer = [4, 1]
b._sliding_window_pattern = [False, True]
b._kv_value_length_swa = 2048
ctx = 4096
expected_per_cell = 4 * 256 * 2 + 1 * 2048 * 2
assert b._mtp_draft_kv_bytes(ctx, flash_attn = False) == ctx * expected_per_cell
def test_none_when_dims_missing(self):
assert _make_backend(nextn = 0)._mtp_draft_kv_bytes(65536) is None
assert _make_backend(kv_key_length = None)._mtp_draft_kv_bytes(65536) is None
@ -232,6 +252,30 @@ class TestSeparateDrafter:
c = b._mtp_draft_kv_bytes(65536, drafter_path = "/m/d.gguf")
assert c == pytest.approx(4 * a)
def test_gemma4_assistant_shares_target_kv(self, monkeypatch):
b = _make_backend(nextn = None)
stub = _StubDrafter(kv_per_token = 2000)
stub._architecture = "gemma4-assistant"
monkeypatch.setattr(b, "_draft_backend_for", lambda path: stub)
assert (
b._mtp_draft_kv_bytes(
65536,
drafter_path = "/m/mtp-gemma4.gguf",
swa_full = True,
)
== 0
)
assert (
b._estimate_mtp_overhead_bytes(
65536,
drafter_path = "/m/mtp-gemma4.gguf",
draft_weights_bytes = GIB,
swa_full = True,
)
== GIB
)
def test_drafter_kv_scales_with_parallel_slots(self, monkeypatch):
# The drafter is served under the same --parallel slots as the main model,
# so a sliding-window drafter's KV grows per slot; the reserve must thread
@ -398,6 +442,7 @@ class TestExtraArgsMtpDetection:
(["--spec-type", "mtp"], True),
(["--spec-type", "ngram-mod,draft-mtp"], True),
(["--spec-type=draft-mtp"], True),
(["--spec_type=draft-mtp"], True),
(["--spec-type", "ngram-mod"], False),
(["--spec-default"], False),
(["-c", "131072"], False),
@ -579,6 +624,7 @@ class TestExtraArgsMtpDetection:
(["--spec-draft-ngl", "0"], True),
(["-ngld", "0"], True),
(["--spec-draft-ngl=0"], True),
(["--spec_draft_ngl=0"], True),
(["--n-gpu-layers-draft", "0"], True),
(["--spec-draft-ngl", "20"], False),
(["--spec-draft-device", "none"], True),
@ -623,6 +669,7 @@ class TestExtraArgsMtpDetection:
[
(["--spec-draft-n-max", "4"], 4),
(["--spec-draft-n-max=6"], 6),
(["--spec_draft_n_max=6"], 6),
(["--spec-type", "draft-mtp", "--spec-draft-n-max", "3"], 3),
(["--spec-draft-n-max", "2", "--spec-draft-n-max", "5"], 5), # last wins
(["--spec-draft-n-max", "notanint"], None),
@ -644,6 +691,7 @@ class TestExtraArgsMtpDetection:
(["--spec-draft-model", "/m/draft.gguf"], "/m/draft.gguf"),
(["-md", "/m/draft.gguf"], "/m/draft.gguf"),
(["--model-draft=/m/draft.gguf"], "/m/draft.gguf"),
(["--model_draft=/m/draft.gguf"], "/m/draft.gguf"),
(["--model-draft", "--spec-type"], None),
(["-c", "4096"], None),
(None, None),
@ -689,6 +737,7 @@ class TestExtraArgsMtpDetection:
(["--cache-type-v-draft", "q4_0"], (None, "q4_0")), # K stays f16, V only
(["--cache-type-k-draft", "q4_0", "--cache-type-v-draft", "q8_0"], ("q4_0", "q8_0")),
(["--cache-type-k-draft=q8_0"], ("q8_0", None)),
(["--cache_type_k_draft=q8_0"], ("q8_0", None)),
(["--cache-type-k", "q8_0"], (None, None)), # main type, not draft
(["-c", "4096"], (None, None)),
(None, (None, None)),
@ -717,8 +766,17 @@ class TestExtraArgsMtpDetection:
"args,expected",
[
(["--ubatch-size", "1024"], 1024),
(["-ub", "4096"], 4096),
(["-ub", "4096"], 2048),
(["--ubatch-size", "0"], 2048),
(["--batch-size", "256", "--ubatch-size", "0"], 256),
(["--batch-size", "-1"], 512),
(["--ubatch-size", "-1"], 2048),
(["--ubatch-size=512"], 512),
(["--ubatch_size=512"], 512),
(["--batch-size", "256"], 256),
(["--batch_size=256"], 256),
(["-b", "256", "-ub", "1024"], 256),
(["-b", "4096"], 512),
(["--ubatch", "2048"], None), # not a real llama-server flag; ignore it
(["-c", "4096"], None),
(None, None),
@ -727,12 +785,76 @@ class TestExtraArgsMtpDetection:
def test_n_ubatch(self, args, expected):
assert _extra_args_n_ubatch(args, env = {}) == expected
def test_n_ubatch_signed_values_cap_at_context(self):
assert (
_extra_args_n_ubatch(
["--batch-size", "-1", "--ubatch-size", "-1"],
env = {},
n_ctx = 4096,
)
== 4096
)
@pytest.mark.parametrize(
"args,expected",
[
(None, True),
(["--flash-attn", "off"], False),
(["--flash-attn", "disabled"], False),
(["--flash-attn", "false"], False),
(["--flash-attn", "0"], False),
(["--flash-attn=off"], False),
(["--flash-attn=disabled"], False),
(["--flash-attn=false"], False),
(["--flash-attn=0"], False),
(["--flash_attn", "off"], False),
(["-fa", "off", "--flash-attn", "auto"], True),
(["-fa", "off", "--flash-attn", "-1"], True),
(["-fa", "off", "--flash-attn", "enabled"], True),
(["-fa", "off", "--flash-attn=true"], True),
(["-fa", "off", "--flash-attn=1"], True),
(["--flash-attn", "off", "-fa"], True),
],
)
def test_flash_attn_last_value_wins(self, args, expected):
assert _flash_attn_enabled_from_args(args) is expected
def test_effective_main_cache_types_follow_env_then_cli(self):
env = {
"LLAMA_ARG_CACHE_TYPE_K": "f32",
"LLAMA_ARG_CACHE_TYPE_V": "q4_0",
}
assert _effective_main_cache_types([], env) == ("f32", "q4_0")
assert _effective_main_cache_types(["--cache-type-v", "f16"], env) == ("f32", "f16")
def test_n_ubatch_env_fallback(self):
# The child honors LLAMA_ARG_UBATCH; it must reach the compute-buffer reserve.
assert _extra_args_n_ubatch([], env = {"LLAMA_ARG_UBATCH": "4096"}) == 4096
# Environment values apply first, then each command-line option overrides
# its own axis before llama.cpp caps ubatch at batch size.
assert _extra_args_n_ubatch([], env = {"LLAMA_ARG_UBATCH": "4096"}) == 2048
assert _extra_args_n_ubatch([], env = {"LLAMA_ARG_BATCH": "256"}) == 256
assert (
_extra_args_n_ubatch(
[],
env = {
"LLAMA_ARG_BATCH": "1024",
"LLAMA_ARG_UBATCH": "4096",
},
)
== 1024
)
assert (
_extra_args_n_ubatch(["-ub", "1024"], env = {"LLAMA_ARG_UBATCH": "4096"}) == 1024
) # CLI wins
assert (
_extra_args_n_ubatch(
["-b", "1024"],
env = {
"LLAMA_ARG_BATCH": "256",
"LLAMA_ARG_UBATCH": "4096",
},
)
== 1024
)
assert _extra_args_n_ubatch([], env = {"LLAMA_ARG_UBATCH": "notint"}) is None
def test_env_main_cache_type_for_budget(self):

View file

@ -36,6 +36,7 @@ def _backend(
vocab = 248320,
embd = 5120,
kv_fixed_mib = 0,
kv_calls = None,
):
"""Backend with the dims the compute buffer reads; KV mocked to a fixed size so the
only slot-dependent term is the compute buffer (485 MiB/slot f32 output x 1.15)."""
@ -43,7 +44,17 @@ def _backend(
b._vocab_size = vocab
b._embedding_length = embd
b._key_length_mla = None
b._estimate_kv_cache_bytes = lambda ctx, t = None, **k: kv_fixed_mib * MIB
def estimate(
ctx,
t = None,
**kwargs,
):
if kv_calls is not None:
kv_calls.append(kwargs)
return kv_fixed_mib * MIB
b._estimate_kv_cache_bytes = estimate
b._can_estimate_kv = lambda: True
return b
@ -55,6 +66,7 @@ def _run(
gpus,
total_by_idx,
overhead_mib = 0,
swa_full = False,
):
return b._slots_that_fit_on_gpu(
n_parallel,
@ -66,7 +78,8 @@ def _run(
FRAC,
int(overhead_mib * MIB),
1,
512,
n_ubatch = 512,
swa_full = swa_full,
)
@ -113,3 +126,16 @@ class TestSlotsThatFitOnGpu:
# base 19500 (= 22500 total at par-independent terms) the same par3 fit holds.
gi, use_fit, slots = _run(_backend(kv_fixed_mib = 3000), 4, 19500, [(0, 24576)], {0: 24576})
assert use_fit is False and slots == 3
def test_swa_full_is_used_for_every_candidate(self):
calls = []
_run(
_backend(kv_calls = calls),
4,
22500,
[(0, 24576)],
{0: 24576},
swa_full = True,
)
assert calls
assert all(call["swa_full"] is True for call in calls)

View file

@ -209,6 +209,13 @@ def test_already_in_target_state_reloads_on_tensor_parallel_change(loaded, reque
assert _target_state(_loaded_backend(loaded), requested) is False
def test_already_in_target_state_reloads_when_swa_full_env_changes(monkeypatch):
backend = _loaded_backend(False)
backend._swa_full = False
monkeypatch.setenv("LLAMA_ARG_SWA_FULL", "1")
assert _target_state(backend, False) is False
def test_already_in_target_state_reconciles_split_mode_extras():
# Tensor engaged via --split-mode in extras (boolean omitted/default False)
# must match a server already running tensor mode -- no spurious reload.

View file

@ -663,6 +663,29 @@ def test_tensor_off_echo_preserves_multi_gpu_fallback():
)
def test_route_dedupe_reloads_when_swa_full_env_changes(monkeypatch):
from models.inference import LoadRequest
inference_routes = _load_inference_routes_module()
backend = _fallback_loaded_backend(layer_preserves_tensor_intent = False)
monkeypatch.setenv("LLAMA_ARG_SWA_FULL", "1")
request = LoadRequest(model_path = "owner/repo")
assert inference_routes._request_matches_loaded_settings(request, backend) is False
def test_route_dedupe_ignores_swa_full_for_diffusion(monkeypatch):
from models.inference import LoadRequest
inference_routes = _load_inference_routes_module()
backend = _fallback_loaded_backend(layer_preserves_tensor_intent = False)
backend._is_diffusion = True
monkeypatch.setenv("LLAMA_ARG_SWA_FULL", "1")
request = LoadRequest(model_path = "owner/repo")
assert inference_routes._request_matches_loaded_settings(request, backend) is True
def test_explicit_split_mode_layer_extras_reloads_after_multi_gpu_fallback():
"""Tensor intent can be dropped via extras too: an explicit --split-mode layer
matches the stored fallback extras but must still reload (reviewer.py P1, #6659)."""

View file

@ -11,7 +11,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"test": "node --experimental-strip-types --test \"tests/**/*.test.ts\"",
"typecheck": "tsc -b --pretty false && tsc -p tsconfig.test.json --pretty false",
"i18n:check": "node --experimental-strip-types --no-warnings src/i18n/check-parity.ts",
"biome:check": "biome check",
"biome:fix": "biome check --write"

View file

@ -11,6 +11,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { resolveReasoningGroupDuration } from "@/features/chat";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
@ -339,9 +340,11 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
});
const persistedDuration = useAuiState(({ message }) => {
const d = (message.metadata?.custom as Record<string, unknown>)
?.reasoningDuration;
return typeof d === "number" ? d : 0;
return resolveReasoningGroupDuration(
message.parts,
startIndex,
message.metadata?.custom as Record<string, unknown> | undefined,
);
});
const [manualOpen, setManualOpen] = useState(false);
@ -412,7 +415,7 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
className="min-w-0 flex-1"
active={isReasoningStreaming}
// Prefer server timing when available.
duration={persistedDuration || duration}
duration={persistedDuration ?? duration}
/>
<div className="flex w-16 shrink-0 justify-end">
{isOpen && !isReasoningStreaming && (

View file

@ -36,6 +36,7 @@ import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { ChatDictationBar } from "@/components/assistant-ui/chat-dictation-bar";
import {
pasteClipboardFiles,
isStudioDictationAvailable,
notifyStudioDictationUnavailable,
} from "@/features/chat";
@ -177,6 +178,7 @@ import {
type ChangeEvent,
type ComponentProps,
type CompositionEvent,
type ClipboardEvent,
type FC,
type KeyboardEvent,
type DragEvent as ReactDragEvent,
@ -1528,6 +1530,24 @@ const Composer: FC<{
);
const { inputProps, isComposing, isComposingRef } =
useImeComposerInputHandlers({ submitOnEnter: true });
const handleFilePaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
pasteClipboardFiles(
event,
async (files) => {
await Promise.all(
files.map((file) => aui.composer().addAttachment(file)),
);
},
() =>
toast.error("Could not paste files.", {
description: "The clipboard item is unsupported, unreadable, or over 20 MB.",
}),
);
},
[aui],
);
const composerText = useAuiState(({ composer }) => composer.text);
// Expand only once the input wraps to a second line, not on first keystroke.
// Latch until cleared so it can't flip-flop at the wrap boundary.
@ -2021,6 +2041,8 @@ const Composer: FC<{
// no effect on Latin / CJK / Devanagari.
dir="auto"
{...inputProps}
addAttachmentOnPaste={false}
onPaste={handleFilePaste}
/>
<ComposerRightControls
disabled={

View file

@ -86,9 +86,15 @@ import {
} from "../utils/last-local-model-load";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
extractDeltaText,
hasUnclosedThinkTag,
parseAssistantContent,
} from "../utils/parse-assistant-content";
import {
countReasoningGroups,
createReasoningDurationTracker,
lastReasoningGroupTextLength,
} from "../utils/reasoning-duration";
import { resolveLoadMaxSeqLength } from "../presets/preset-policy";
import {
generateAudio,
@ -617,67 +623,6 @@ function estimateTokenCount(text: string): number | undefined {
return Math.max(1, Math.round(trimmed.length / 4));
}
/**
* Normalize a streamed `delta.content` to a plain text string.
*
* OpenAI Chat Completions originally typed `delta.content` as a string, but
* some providers now emit an array of structured content parts; concatenating
* those directly would stringify each as `[object Object]`. This guards that.
*
* Handled part shapes:
* { type: "text" | "output_text", text | content: "..." } text body
* { type: "thinking" | "reasoning", thinking | text: "..." } wrapped as
* inline `<think>...</think>` so `parseAssistantContent` lifts it into
* a reasoning part (else Mistral magistral and similar reasoning-part
* providers lose their thinking panel).
*
* Unknown part types are skipped better to drop a stray field than
* stringify an object into the rendered chat.
*/
function extractDeltaText(delta: unknown): string {
const extractReasoningText = (payload: unknown): string => {
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
return payload.map((item) => extractReasoningText(item)).join("");
}
if (!payload || typeof payload !== "object") return "";
const obj = payload as Record<string, unknown>;
for (const key of ["thinking", "text", "content", "reasoning", "summary"]) {
if (key in obj) {
const text = extractReasoningText(obj[key]);
if (text) return text;
}
}
return "";
};
if (typeof delta === "string") return delta;
if (!Array.isArray(delta)) return "";
let out = "";
for (const part of delta) {
if (typeof part === "string") {
out += part;
continue;
}
if (!part || typeof part !== "object") continue;
const obj = part as {
type?: string;
text?: string;
content?: string;
thinking?: string;
};
if (obj.type === "text" || obj.type === "output_text") {
if (typeof obj.text === "string") out += obj.text;
else if (typeof obj.content === "string") out += obj.content;
} else if (obj.type === "thinking" || obj.type === "reasoning") {
const thinking = extractReasoningText(obj);
if (thinking) out += `<think>${thinking}</think>`;
}
}
return out;
}
function buildTiming(
streamStartTime: number,
totalChunks: number,
@ -1562,6 +1507,8 @@ async function autoLoadSmallestModel(): Promise<{
// The safetensors fallback omits both fields and uses HF auto-placement.
gpu_ids?: number[];
gpu_memory_mode?: "auto" | "manual";
cache_type_kv?: string | null;
tensor_parallel?: boolean | null;
}): Promise<boolean> {
const validation = await validateModel({
...payload,
@ -1650,6 +1597,8 @@ async function autoLoadSmallestModel(): Promise<{
max_seq_length: fitMaxSeqLength,
is_lora: false,
gguf_variant: candidate.ggufVariant,
cache_type_kv: config.kvCacheDtype,
tensor_parallel: config.tensorParallel,
// The same remembered-derived GPU pick the load below sends.
...(candidate.kind === "gguf"
? {
@ -2939,8 +2888,7 @@ export function createOpenAIStreamAdapter(
owner: serverCancel,
});
let cumulativeText = "";
let reasoningStartAt: number | null = null;
let reasoningDuration = 0;
const reasoningDurationTracker = createReasoningDurationTracker();
// True while wrapping a `delta.reasoning_content` stream in
// <think>...</think> for parseAssistantContent. Lives outside the
// SSE loop because the close tag fires when content arrives.
@ -3080,9 +3028,11 @@ export function createOpenAIStreamAdapter(
return merged;
};
const closeReasoningContent = () => {
if (!reasoningContentOpen) return;
cumulativeText += "</think>";
reasoningContentOpen = false;
if (reasoningContentOpen) {
cumulativeText += "</think>";
reasoningContentOpen = false;
}
reasoningDurationTracker.finishGroup();
};
// Anthropic document_citations payload, converted to Sources-panel
// parts at end-of-stream so inline [N] markers have matching entries.
@ -3638,8 +3588,9 @@ export function createOpenAIStreamAdapter(
const reasoningMs = (
chunk as { _reasoningDurationMs?: number } | null | undefined
)?._reasoningDurationMs;
if (typeof reasoningMs === "number" && Number.isFinite(reasoningMs)) {
reasoningDuration = Math.max(0, Math.round(reasoningMs / 1000));
if (
reasoningDurationTracker.recordServerDuration(reasoningMs)
) {
continue;
}
@ -3783,7 +3734,7 @@ export function createOpenAIStreamAdapter(
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
custom: reasoningDurationTracker.metadata(),
},
};
}
@ -4078,7 +4029,7 @@ export function createOpenAIStreamAdapter(
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
custom: reasoningDurationTracker.metadata(),
},
};
continue;
@ -4117,7 +4068,10 @@ export function createOpenAIStreamAdapter(
}
const rawDelta = chunk.choices?.[0]?.delta?.content;
// Normalize structured delta.content (mistral magistral).
const delta = extractDeltaText(rawDelta);
const {
text: delta,
structuredReasoningContinues,
} = extractDeltaText(rawDelta);
// Latest Gemini text-part thoughtSignature for next-turn replay.
const deltaExtraContent = (
chunk.choices?.[0]?.delta as
@ -4271,7 +4225,7 @@ export function createOpenAIStreamAdapter(
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
custom: reasoningDurationTracker.metadata(),
},
};
continue;
@ -4288,6 +4242,7 @@ export function createOpenAIStreamAdapter(
if (reasoning) {
if (!reasoningContentOpen) {
reasoningDurationTracker.startGroup();
cumulativeText += `<think>${reasoning}`;
reasoningContentOpen = true;
} else {
@ -4295,7 +4250,9 @@ export function createOpenAIStreamAdapter(
}
}
if (delta) {
closeReasoningContent();
if (reasoningContentOpen) {
closeReasoningContent();
}
cumulativeText += delta;
}
// Strip a trailing ${...} template-literal fragment from
@ -4306,35 +4263,48 @@ export function createOpenAIStreamAdapter(
"",
);
}
const textParts = parseAssistantContent(cumulativeText);
const assistantContent = buildAssistantContent(cumulativeText);
// Fallback when no server-side reasoning_summary arrives.
const parsedReasoningGroupCount =
countReasoningGroups(assistantContent);
if (
textParts.some((part) => part.type === "reasoning") &&
!reasoningStartAt
parsedReasoningGroupCount >
reasoningDurationTracker.groupCount
) {
reasoningStartAt = Date.now();
}
if (
hasClosedThinkTag(cumulativeText) &&
reasoningStartAt &&
!reasoningDuration
) {
reasoningDuration = Math.round(
(Date.now() - reasoningStartAt) / 1000,
reasoningDurationTracker.startGroup(
parsedReasoningGroupCount - 1,
);
}
if (parsedReasoningGroupCount > 0) {
// Providers that close every reasoning block atomically
// (structured parts wrapped as <think>..</think>) end the group
// on each chunk. Reopen while the reasoning text is still
// growing so the timer spans the whole pass.
reasoningDurationTracker.resumeGroup(
parsedReasoningGroupCount - 1,
lastReasoningGroupTextLength(assistantContent),
);
}
if (
reasoningDurationTracker.hasActiveGroup &&
!reasoningContentOpen &&
!structuredReasoningContinues &&
!hasUnclosedThinkTag(cumulativeText)
) {
reasoningDurationTracker.finishGroup();
}
if (textParts.length > 0 || toolCallParts.length > 0) {
if (assistantContent.length > 0) {
yield {
content: buildAssistantContent(cumulativeText),
content: assistantContent,
metadata: {
timing: buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
),
custom: { reasoningDuration },
custom: reasoningDurationTracker.metadata(),
},
};
}
@ -4437,12 +4407,7 @@ export function createOpenAIStreamAdapter(
);
// Finalize reasoning-only streams.
if (reasoningStartAt && !reasoningDuration) {
reasoningDuration = Math.max(
0,
Math.round((Date.now() - reasoningStartAt) / 1000),
);
}
reasoningDurationTracker.finishGroup();
yield {
content: [
...buildAssistantContent(cumulativeText),
@ -4452,7 +4417,7 @@ export function createOpenAIStreamAdapter(
metadata: {
timing: finalTiming,
custom: {
reasoningDuration,
...reasoningDurationTracker.metadata(),
// Persisted refusal flag driving the two-pass prune.
anthropicRefusal: anthropicRefusalSeen || undefined,
serverTimings: meta?.timings ?? undefined,
@ -4511,6 +4476,30 @@ export function createOpenAIStreamAdapter(
});
}
}
if (!abortSignal.aborted) {
closeReasoningContent();
const partialContent = buildAssistantContent(cumulativeText);
if (partialContent.length > 0) {
const partialTiming = buildTiming(
streamStartTime,
totalChunks,
firstTokenTime,
Date.now() - streamStartTime,
estimateTokenCount(cumulativeText),
toolCallParts.length,
);
yield {
content: partialContent,
metadata: {
timing: partialTiming,
custom: {
...reasoningDurationTracker.metadata(),
timing: partialTiming,
},
},
};
}
}
throw err;
} finally {
runSignal.removeEventListener("abort", onAbortCancel);

View file

@ -185,6 +185,8 @@ export async function validateModel(
// /load. Default placement is sized against the selected GPUs.
max_seq_length: payload.max_seq_length,
load_in_4bit: payload.load_in_4bit,
cache_type_kv: payload.cache_type_kv ?? null,
tensor_parallel: payload.tensor_parallel ?? false,
gpu_ids: payload.gpu_ids,
// Manual placement is an explicit override: Auto layers use llama.cpp
// --fit, while a pinned layer count is owned by the user. Tell validate

View file

@ -824,6 +824,8 @@ export function useChatModelRuntime() {
load_in_4bit: true,
is_lora: isLora,
gguf_variant: ggufVariant ?? null,
cache_type_kv: loadKvCacheDtype,
tensor_parallel: loadTensorParallel,
gpu_ids: validateGpuIds ?? undefined,
...(isGguf
? {

View file

@ -88,8 +88,10 @@ export { StopRunningChatsDialog } from "./components/stop-running-chats-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export type { ProjectRecord } from "./types";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { pasteClipboardFiles } from "./utils/clipboard-files";
export { listStoredChatThreads } from "./utils/chat-history-storage";
export { emitChatAttachmentDeleted } from "./utils/chat-attachment-events";
export { resolveReasoningGroupDuration } from "./utils/reasoning-duration";
export { ArtifactCard } from "./artifacts/artifact-card";
export { ResearchMessage } from "./components/research-message";
export {

View file

@ -35,6 +35,7 @@ import { isTauri } from "@/lib/api-base";
import { isDownloadCancelled } from "@/lib/native-files";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { pasteClipboardFiles } from "./utils/clipboard-files";
import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
@ -118,6 +119,7 @@ import {
} from "./provider-capabilities";
import {
type CompositionEvent,
type ClipboardEvent,
type FC,
type KeyboardEvent,
type MutableRefObject,
@ -834,7 +836,7 @@ export function SharedComposer({
}, [text]);
const addFiles = useCallback(
(files: FileList | null) => {
(files: FileList | readonly File[] | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
@ -866,6 +868,29 @@ export function SharedComposer({
[setPendingAudioStore, attachUnavailableReason],
);
const handleFilePaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
pasteClipboardFiles(
event,
async (files) => {
const supported = files.some(
(file) =>
(file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) ||
(file.type.match(/^image\/(jpeg|png|webp|gif)$/i) &&
file.size <= MAX_IMAGE_SIZE),
);
if (!supported) throw new Error("Unsupported compare attachment");
addFiles(files);
},
() =>
toast.error("Could not paste files.", {
description: "Compare supports images and audio within the attachment size limits.",
}),
);
},
[addFiles],
);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
@ -1097,6 +1122,8 @@ export function SharedComposer({
gguf_variant: sel.ggufVariant ?? null,
trust_remote_code: loadTrustRemoteCode,
chat_template_override: effectiveChatTemplateOverride,
cache_type_kv: ownConfig.kvCacheDtype ?? null,
tensor_parallel: effectiveTensorParallel,
// Scope the validate to the picked GPUs. GGUF-only, like the load
// below: a non-GGUF target must not inherit a hidden GGUF GPU pick.
...(targetIsGguf
@ -1695,6 +1722,7 @@ export function SharedComposer({
setText(e.currentTarget.value);
}}
onKeyDown={onKeyDown}
onPaste={handleFilePaste}
onBlur={() => {
// Mac: switching input methods can fire compositionstart without a
// matching compositionend, leaving composingRef pinned. The OS always

View file

@ -0,0 +1,248 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { isTauri } from "@/lib/api-base";
const MAX_NATIVE_IMAGE_DIMENSION = 8192;
const MAX_NATIVE_IMAGE_RGBA_BYTES = 64 * 1024 * 1024;
const MAX_CLIPBOARD_BYTES = 20 * 1024 * 1024;
const MAX_CLIPBOARD_FILES = 8;
type ClipboardPasteEvent = {
readonly clipboardData: DataTransfer | null;
readonly defaultPrevented: boolean;
readonly isTrusted: boolean;
preventDefault: () => void;
};
type NativeClipboardFile = {
readonly name: string;
readonly mimeType: string;
readonly base64: string;
};
function browserClipboardFiles(clipboardData: DataTransfer): File[] {
const files = Array.from(clipboardData.files).filter((file) => file.size > 0);
if (files.length > 0) return files;
return Array.from(clipboardData.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null && file.size > 0);
}
function clipboardTypes(clipboardData: DataTransfer): string[] {
return Array.from(clipboardData.types, (type) => type.toLowerCase());
}
function clipboardHasLocalFileUri(
clipboardData: DataTransfer,
types: readonly string[],
): boolean {
const uriTypes = types.filter(
(type) => type.includes("uri-list") || type.includes("urilist"),
);
for (const type of uriTypes) {
try {
if (
clipboardData
.getData(type)
.split(/\r?\n/)
.some((line) => line.trim().toLowerCase().startsWith("file:"))
) {
return true;
}
} catch {
return false;
}
}
return false;
}
function clipboardHasPlainText(clipboardData: DataTransfer): boolean {
try {
return clipboardData.getData("text/plain").length > 0;
} catch {
return true;
}
}
function validDimension(value: number): boolean {
return (
Number.isSafeInteger(value) &&
value > 0 &&
value <= MAX_NATIVE_IMAGE_DIMENSION
);
}
function canvasPng(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}
function isLinuxDesktop(): boolean {
if (typeof navigator === "undefined") return false;
return `${navigator.platform} ${navigator.userAgent}`.toLowerCase().includes("linux");
}
async function readNativeClipboardFiles(): Promise<File[]> {
const { invoke } = await import("@tauri-apps/api/core");
const nativeFiles = await invoke<NativeClipboardFile[]>(
"read_native_clipboard_files",
);
if (nativeFiles.length > MAX_CLIPBOARD_FILES) return [];
let totalBytes = 0;
const files: File[] = [];
for (const file of nativeFiles) {
if (
!file.name ||
file.name.length > 255 ||
file.name.includes("/") ||
file.name.includes("\0") ||
file.base64.length > Math.ceil((MAX_CLIPBOARD_BYTES * 4) / 3) + 4
) {
return [];
}
const binary = globalThis.atob(file.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
totalBytes += bytes.byteLength;
if (totalBytes > MAX_CLIPBOARD_BYTES) return [];
files.push(
new File([bytes], file.name, {
type: file.mimeType || "application/octet-stream",
lastModified: Date.now(),
}),
);
}
return files;
}
async function readLinuxClipboardImage(): Promise<File | null> {
const { invoke } = await import("@tauri-apps/api/core");
const raw = await invoke<ArrayBuffer | Uint8Array>("read_native_clipboard_png");
const png = Uint8Array.from(raw instanceof Uint8Array ? raw : new Uint8Array(raw));
if (png.byteLength === 0 || png.byteLength > MAX_CLIPBOARD_BYTES) return null;
return new File([png], "pasted-image.png", {
type: "image/png",
lastModified: Date.now(),
});
}
async function readNativeClipboardImage(): Promise<File | null> {
let image: Awaited<ReturnType<
typeof import("@tauri-apps/plugin-clipboard-manager").readImage
>> | null = null;
try {
if (isLinuxDesktop()) return await readLinuxClipboardImage();
const { readImage } = await import("@tauri-apps/plugin-clipboard-manager");
image = await readImage();
const { width, height } = await image.size();
if (!validDimension(width) || !validDimension(height)) return null;
const expectedRgbaBytes = width * height * 4;
if (expectedRgbaBytes > MAX_NATIVE_IMAGE_RGBA_BYTES) return null;
const rgba = await image.rgba();
if (rgba.byteLength !== expectedRgbaBytes) return null;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
try {
const context = canvas.getContext("2d");
if (!context) return null;
const pixels = new Uint8ClampedArray(
rgba.buffer as ArrayBuffer,
rgba.byteOffset,
rgba.byteLength,
);
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvasPng(canvas);
if (!blob || blob.size === 0 || blob.size > MAX_CLIPBOARD_BYTES) {
return null;
}
return new File([blob], "pasted-image.png", {
type: "image/png",
lastModified: Date.now(),
});
} finally {
canvas.width = 0;
canvas.height = 0;
}
} catch {
return null;
} finally {
if (image) {
try {
await image.close();
} catch {
// The native resource may already have been released after an invoke failure.
}
}
}
}
function addClipboardFiles(
files: readonly File[],
addFiles: (files: readonly File[]) => void | Promise<void>,
onError?: () => void,
): void {
void Promise.resolve(addFiles(files)).catch(() => onError?.());
}
function addNativeClipboardFiles(
addFiles: (files: readonly File[]) => void | Promise<void>,
onError?: () => void,
): void {
void (async () => {
try {
const files = await readNativeClipboardFiles();
if (files.length > 0) return files;
} catch {
// The clipboard may contain image pixels instead of file paths.
}
const image = await readNativeClipboardImage();
return image ? [image] : [];
})().then((files) => {
if (files.length > 0) addClipboardFiles(files, addFiles, onError);
else onError?.();
});
}
export function pasteClipboardFiles(
event: ClipboardPasteEvent,
addFiles: (files: readonly File[]) => void | Promise<void>,
onError?: () => void,
): void {
const { clipboardData } = event;
if (clipboardData) {
const browserFiles = browserClipboardFiles(clipboardData);
if (browserFiles.length > 0) {
event.preventDefault();
addClipboardFiles(browserFiles, addFiles, onError);
return;
}
}
if (!isTauri || !event.isTrusted || event.defaultPrevented) return;
if (!clipboardData) {
addNativeClipboardFiles(addFiles, onError);
return;
}
const types = clipboardTypes(clipboardData);
const advertisesImage = types.some((type) => type.startsWith("image/"));
const advertisesFile =
types.includes("files") ||
types.some((type) => type.includes("copied-files")) ||
clipboardHasLocalFileUri(clipboardData, types);
if (!advertisesImage && !advertisesFile && clipboardHasPlainText(clipboardData)) {
return;
}
if (advertisesImage || advertisesFile) event.preventDefault();
addNativeClipboardFiles(addFiles, onError);
}

View file

@ -8,6 +8,78 @@ type ContentPart = NonNullable<ChatModelRunResult["content"]>[number];
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
/**
* Normalize streamed string or structured delta content to inline text.
* Structured reasoning-only chunks remain distinguishable so their fallback
* timer can span consecutive chunks even though each chunk carries closed tags.
*/
export function extractDeltaText(delta: unknown): {
text: string;
structuredReasoningContinues: boolean;
} {
const extractReasoningText = (payload: unknown): string => {
if (typeof payload === "string") return payload;
if (Array.isArray(payload)) {
return payload.map((item) => extractReasoningText(item)).join("");
}
if (!payload || typeof payload !== "object") return "";
const obj = payload as Record<string, unknown>;
for (const key of ["thinking", "text", "content", "reasoning", "summary"]) {
if (key in obj) {
const text = extractReasoningText(obj[key]);
if (text) return text;
}
}
return "";
};
if (typeof delta === "string") {
return { text: delta, structuredReasoningContinues: false };
}
if (!Array.isArray(delta)) {
return { text: "", structuredReasoningContinues: false };
}
let text = "";
let structuredReasoningContinues = false;
for (const part of delta) {
if (typeof part === "string") {
text += part;
if (part) {
structuredReasoningContinues = false;
}
continue;
}
if (!part || typeof part !== "object") continue;
const obj = part as {
type?: string;
text?: string;
content?: string;
thinking?: string;
};
if (obj.type === "text" || obj.type === "output_text") {
const visibleText =
typeof obj.text === "string"
? obj.text
: typeof obj.content === "string"
? obj.content
: "";
text += visibleText;
if (visibleText) {
structuredReasoningContinues = false;
}
} else if (obj.type === "thinking" || obj.type === "reasoning") {
const thinking = extractReasoningText(obj);
if (thinking) {
text += `${THINK_OPEN_TAG}${thinking}${THINK_CLOSE_TAG}`;
structuredReasoningContinues = true;
}
}
}
return { text, structuredReasoningContinues };
}
// ContentPart from @assistant-ui/react has readonly fields, so coalescing via
// `last.text += text` fails (TS2540). Instead replace the last element with a
// fresh merged object: same allocation cost as mutation but type-safe.
@ -64,6 +136,6 @@ export function parseAssistantContent(
return parts;
}
export function hasClosedThinkTag(raw: string): boolean {
return raw.includes(THINK_CLOSE_TAG);
export function hasUnclosedThinkTag(raw: string): boolean {
return raw.lastIndexOf(THINK_OPEN_TAG) > raw.lastIndexOf(THINK_CLOSE_TAG);
}

View file

@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
type MessagePartLike = {
type?: unknown;
text?: unknown;
};
type ReasoningMetadata = {
reasoningDuration?: unknown;
reasoningDurations?: unknown;
};
function asDuration(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function getReasoningGroupIndex(
parts: readonly MessagePartLike[],
endIndex: number,
): number {
let index = -1;
let previousWasReasoning = false;
const limit = Math.min(endIndex, parts.length - 1);
for (let partIndex = 0; partIndex <= limit; partIndex += 1) {
const isReasoning = parts[partIndex]?.type === "reasoning";
if (isReasoning && !previousWasReasoning) {
index += 1;
}
previousWasReasoning = isReasoning;
}
return index;
}
export function countReasoningGroups(
parts: readonly MessagePartLike[],
): number {
return getReasoningGroupIndex(parts, parts.length - 1) + 1;
}
/**
* Total reasoning text in the LAST reasoning group (the group any new
* reasoning would join). The adapter compares this across chunks to tell "the
* model is still thinking" from "the model has moved on to the answer": a
* provider that closes every reasoning block atomically would otherwise freeze
* the group's timer at its first close.
*/
export function lastReasoningGroupTextLength(
parts: readonly MessagePartLike[],
): number {
let total = 0;
let inGroup = false;
for (let index = parts.length - 1; index >= 0; index -= 1) {
if (parts[index]?.type !== "reasoning") {
if (inGroup) break;
continue;
}
inGroup = true;
const text = parts[index]?.text;
total += typeof text === "string" ? text.length : 0;
}
return total;
}
export function resolveReasoningGroupDuration(
parts: readonly MessagePartLike[],
startIndex: number,
custom: ReasoningMetadata | null | undefined,
): number | undefined {
const index = getReasoningGroupIndex(parts, startIndex);
if (index < 0) {
return undefined;
}
if (Array.isArray(custom?.reasoningDurations)) {
return asDuration(custom.reasoningDurations[index]);
}
if (index !== getReasoningGroupIndex(parts, parts.length - 1)) {
return undefined;
}
return asDuration(custom?.reasoningDuration);
}
export function createReasoningDurationTracker(
now: () => number = Date.now,
) {
let durations: number[] = [];
// First time each group index became visible. A group can be closed and
// reopened -- a provider that emits several complete <think>...</think>
// blocks in a row has them coalesced into one rendered group -- so the
// duration is always measured from the first sighting, not the last.
const startedAt: number[] = [];
let activeIndex: number | null = null;
let groupCount = 0;
// Reasoning text seen so far per group, used to decide whether a closed
// group is still growing and should reopen.
const reasoningLength: number[] = [];
// The group a server summary would land on. The backend emits one summary at
// the end of each visible reasoning pass, before the next pass can begin, so
// "the group that started most recently" is the correct target. (A FIFO queue
// is tempting but wrong: it mis-assigns as soon as one group has no summary.)
let serverSummaryTargetIndex: number | null = null;
// Indices whose duration came from the server; local timing must not
// overwrite an authoritative value.
const serverClaimed = new Set<number>();
const setDuration = (index: number, duration: number) => {
if (durations[index] === duration) {
return;
}
const next = [...durations];
next[index] = duration;
durations = next;
};
const measure = (index: number, finishedAt: number) => {
if (serverClaimed.has(index)) {
return;
}
const from = startedAt[index];
if (from === undefined) {
return;
}
setDuration(index, Math.max(0, Math.round((finishedAt - from) / 1000)));
};
const finishGroupAt = (finishedAt: number) => {
if (activeIndex === null) {
return;
}
const index = activeIndex;
activeIndex = null;
measure(index, finishedAt);
};
return {
get groupCount() {
return groupCount;
},
get hasActiveGroup() {
return activeIndex !== null;
},
startGroup(index = groupCount) {
if (activeIndex === index) {
return;
}
const at = now();
finishGroupAt(at);
// A single delta can reveal more than one group at once. Any index we
// skipped became visible and closed within this same chunk, so give it a
// measured zero rather than leaving a hole in the persisted array.
for (let skipped = groupCount; skipped < index; skipped += 1) {
if (startedAt[skipped] === undefined) {
startedAt[skipped] = at;
}
measure(skipped, at);
}
if (startedAt[index] === undefined) {
startedAt[index] = at;
}
activeIndex = index;
groupCount = Math.max(groupCount, index + 1);
serverSummaryTargetIndex = index;
},
/**
* Reopen a group that already closed, but only while its reasoning text is
* still growing. Providers that emit each reasoning block as a complete
* <think>...</think> chunk close the group on every chunk; without this the
* group would freeze at the first close. Gating on growth is what keeps the
* timer from running on into the answer.
*/
resumeGroup(index: number, currentReasoningLength: number) {
const seen = reasoningLength[index] ?? 0;
if (currentReasoningLength <= seen) {
return;
}
reasoningLength[index] = currentReasoningLength;
if (activeIndex === index || startedAt[index] === undefined) {
return;
}
finishGroupAt(now());
activeIndex = index;
},
finishGroup() {
finishGroupAt(now());
},
recordServerDuration(reasoningMs: unknown): boolean {
if (
typeof reasoningMs !== "number" ||
!Number.isFinite(reasoningMs) ||
reasoningMs < 0
) {
return false;
}
if (serverSummaryTargetIndex !== null) {
serverClaimed.add(serverSummaryTargetIndex);
setDuration(
serverSummaryTargetIndex,
Math.max(0, Math.round(reasoningMs / 1000)),
);
serverSummaryTargetIndex = null;
}
return true;
},
metadata() {
if (durations.length === 0) {
return {};
}
return {
reasoningDuration: durations.at(-1) ?? 0,
reasoningDurations: durations,
};
},
};
}

View file

@ -0,0 +1,236 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import {
countReasoningGroups,
createReasoningDurationTracker,
lastReasoningGroupTextLength,
resolveReasoningGroupDuration,
} from "../src/features/chat/utils/reasoning-duration.ts";
import { extractDeltaText } from "../src/features/chat/utils/parse-assistant-content.ts";
const separatedReasoning = [
{ type: "reasoning" },
{ type: "tool-call" },
{ type: "reasoning" },
{ type: "text" },
];
test("selects per-group durations while preserving legacy messages", () => {
const current = {
reasoningDuration: 5,
reasoningDurations: [2, 5],
};
assert.equal(resolveReasoningGroupDuration(separatedReasoning, 0, current), 2);
assert.equal(resolveReasoningGroupDuration(separatedReasoning, 2, current), 5);
assert.equal(countReasoningGroups(separatedReasoning), 2);
const legacy = { reasoningDuration: 5 };
assert.equal(
resolveReasoningGroupDuration(separatedReasoning, 0, legacy),
undefined,
);
assert.equal(resolveReasoningGroupDuration(separatedReasoning, 2, legacy), 5);
const contiguous = [
{ type: "reasoning" },
{ type: "reasoning" },
{ type: "text" },
];
assert.equal(countReasoningGroups(contiguous), 1);
assert.equal(
resolveReasoningGroupDuration(contiguous, 0, {
reasoningDurations: [3],
}),
3,
);
});
test("tracks the exact reasoning, tool, reasoning sequence", () => {
let now = 0;
const tracker = createReasoningDurationTracker(() => now);
tracker.startGroup();
now = 1_200;
tracker.recordServerDuration(2_000);
tracker.finishGroup();
tracker.startGroup();
now = 5_600;
tracker.recordServerDuration(5_000);
tracker.finishGroup();
assert.deepEqual(tracker.metadata(), {
reasoningDuration: 5,
reasoningDurations: [2, 5],
});
});
test("keeps groups aligned when summaries are missing or orphaned", () => {
let now = 0;
const tracker = createReasoningDurationTracker(() => now);
tracker.startGroup();
now = 2_000;
tracker.finishGroup();
tracker.startGroup();
now = 7_000;
tracker.recordServerDuration(5_000);
tracker.finishGroup();
tracker.recordServerDuration(9_000);
assert.deepEqual(tracker.metadata(), {
reasoningDuration: 5,
reasoningDurations: [2, 5],
});
});
test("accepts zero after closure and rejects malformed server timing", () => {
let now = 0;
const tracker = createReasoningDurationTracker(() => now);
tracker.startGroup();
now = 1_000;
tracker.finishGroup();
assert.equal(tracker.recordServerDuration(0), true);
assert.equal(tracker.recordServerDuration(-1), false);
assert.deepEqual(tracker.metadata(), {
reasoningDuration: 0,
reasoningDurations: [0],
});
});
test("omits unknown timing and falls back to elapsed time", () => {
let now = 0;
const tracker = createReasoningDurationTracker(() => now);
tracker.startGroup();
assert.deepEqual(tracker.metadata(), {});
now = 3_200;
tracker.finishGroup();
assert.deepEqual(tracker.metadata(), {
reasoningDuration: 3,
reasoningDurations: [3],
});
});
test("keeps structured reasoning active only when it is the final content", () => {
assert.deepEqual(
extractDeltaText([{ type: "reasoning", text: "First" }]),
{
text: "<think>First</think>",
structuredReasoningContinues: true,
},
);
assert.deepEqual(
extractDeltaText([
{ type: "reasoning", text: "Last thought" },
{ type: "text", text: "Answer" },
]),
{
text: "<think>Last thought</think>Answer",
structuredReasoningContinues: false,
},
);
assert.deepEqual(
extractDeltaText([
{ type: "text", text: "Preface" },
{ type: "reasoning", text: "First thought" },
]),
{
text: "Preface<think>First thought</think>",
structuredReasoningContinues: true,
},
);
});
test("keeps a coalesced reasoning group growing across atomic blocks", () => {
let now = 1_770_000_000_000;
const tracker = createReasoningDurationTracker(() => now);
// A provider that closes every reasoning block in its own chunk still
// belongs to ONE rendered group, so the timer must span all of them.
tracker.startGroup();
tracker.resumeGroup(0, "first block".length);
tracker.finishGroup();
now += 3_000;
tracker.resumeGroup(0, "first blocksecond block".length);
tracker.finishGroup();
// The answer that follows adds no reasoning text, so the timer stops here.
now += 3_000;
tracker.resumeGroup(0, "first blocksecond block".length);
tracker.finishGroup();
assert.deepEqual(tracker.metadata(), {
reasoningDuration: 3,
reasoningDurations: [3],
});
});
test("never persists a hole when one delta reveals several groups", () => {
let now = 1_770_000_000_000;
const tracker = createReasoningDurationTracker(() => now);
// Index 0 was never started explicitly: it became visible and closed inside
// the same chunk that revealed index 1.
tracker.startGroup(1);
now += 4_000;
tracker.finishGroup();
const metadata = tracker.metadata();
const durations = metadata.reasoningDurations as number[];
assert.equal(durations.length, 2);
assert.ok(durations.every((value) => typeof value === "number"));
assert.deepEqual(JSON.parse(JSON.stringify(durations)), [0, 4]);
});
test("a server duration is never overwritten by local timing", () => {
let now = 1_770_000_000_000;
const tracker = createReasoningDurationTracker(() => now);
tracker.startGroup();
tracker.recordServerDuration(2_000);
now += 30_000;
tracker.resumeGroup(0, 99);
tracker.finishGroup();
assert.deepEqual(tracker.metadata(), {
reasoningDuration: 2,
reasoningDurations: [2],
});
});
test("lastReasoningGroupTextLength measures only the last reasoning group", () => {
assert.equal(
lastReasoningGroupTextLength([
{ type: "reasoning", text: "aaaa" },
{ type: "tool-call" },
{ type: "reasoning", text: "bb" },
{ type: "reasoning", text: "c" },
]),
3,
);
// The answer that follows is not reasoning, so it does not count -- but the
// group itself is still measured, which is what lets resumeGroup see that the
// reasoning has stopped growing.
assert.equal(
lastReasoningGroupTextLength([
{ type: "reasoning", text: "aaaa" },
{ type: "text", text: "answer" },
]),
4,
);
assert.equal(
lastReasoningGroupTextLength([{ type: "text", text: "answer only" }]),
0,
);
assert.equal(lastReasoningGroupTextLength([]), 0);
});

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["tests"]
}

View file

@ -5566,10 +5566,15 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
name = "unsloth-studio"
version = "2026.4.8"
dependencies = [
"arboard",
"base64 0.22.1",
"dirs",
"elevated-command",
"fix-path-env",
"gdk",
"gdk-pixbuf",
"glib",
"gtk",
"hmac",
"libc",
"log",

View file

@ -26,6 +26,7 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
tauri-plugin-opener = "2.5.4"
tauri-plugin-updater = "2"
tauri-plugin-clipboard-manager = "2"
arboard = "3.6.1"
tauri-plugin-dialog = "2"
rand = "0.10.0"
tauri-plugin-notification = "2.3.3"
@ -36,6 +37,10 @@ tauri-plugin-window-state = "2"
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
gdk = "0.18"
gdk-pixbuf = "0.18"
glib = "0.18"
gtk = "0.18"
elevated-command = "1.1.2"
[target.'cfg(windows)'.dependencies]

View file

@ -29,6 +29,7 @@
},
"updater:default",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-image",
"window-state:default"
]
}

View file

@ -8,6 +8,7 @@ mod desktop_update_policy;
mod diagnostics;
mod install;
mod native_backend_lease;
mod native_clipboard;
mod native_file_dialogs;
mod native_intents;
mod native_path_policy;
@ -218,6 +219,8 @@ fn main() {
desktop_update_policy::check_desktop_manual_update,
desktop_update_policy::desktop_update_policy,
diagnostics::collect_support_diagnostics,
native_clipboard::read_native_clipboard_files,
native_clipboard::read_native_clipboard_png,
native_file_dialogs::save_native_file,
native_file_dialogs::pick_native_chat_import,
native_intents::drain_native_intents,

View file

@ -0,0 +1,442 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::Serialize;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
const MAX_CLIPBOARD_IMAGE_DIMENSION: i32 = 8192;
const MAX_CLIPBOARD_RGBA_BYTES: u64 = 64 * 1024 * 1024;
const MAX_CLIPBOARD_PNG_BYTES: usize = 20 * 1024 * 1024;
const MAX_CLIPBOARD_SOURCE_BYTES: u64 = 20 * 1024 * 1024;
const MAX_CLIPBOARD_TOTAL_BYTES: u64 = 20 * 1024 * 1024;
const MAX_CLIPBOARD_FILES: usize = 8;
const MAX_CLIPBOARD_CANDIDATES: usize = 32;
#[cfg(target_os = "linux")]
const MAX_CLIPBOARD_URI_BYTES: usize = 64 * 1024;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeClipboardFile {
name: String,
mime_type: String,
base64: String,
}
fn validate_dimensions(width: i32, height: i32) -> Result<(), String> {
if width <= 0
|| height <= 0
|| width > MAX_CLIPBOARD_IMAGE_DIMENSION
|| height > MAX_CLIPBOARD_IMAGE_DIMENSION
{
return Err("Clipboard image dimensions are invalid or too large.".to_string());
}
let rgba_bytes = (width as u64)
.checked_mul(height as u64)
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| "Clipboard image dimensions overflow.".to_string())?;
if rgba_bytes > MAX_CLIPBOARD_RGBA_BYTES {
return Err("Clipboard image pixel data is too large.".to_string());
}
Ok(())
}
#[cfg(target_os = "linux")]
fn validate_png_bytes(png: &[u8]) -> Result<(), String> {
const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if png.len() < 24 || png.len() > MAX_CLIPBOARD_PNG_BYTES || &png[..8] != SIGNATURE {
return Err("Clipboard PNG data is invalid or too large.".to_string());
}
if &png[12..16] != b"IHDR" {
return Err("Clipboard PNG header is invalid.".to_string());
}
let width = u32::from_be_bytes(png[16..20].try_into().unwrap());
let height = u32::from_be_bytes(png[20..24].try_into().unwrap());
let width = i32::try_from(width).map_err(|_| "Clipboard PNG width is invalid.".to_string())?;
let height =
i32::try_from(height).map_err(|_| "Clipboard PNG height is invalid.".to_string())?;
validate_dimensions(width, height)
}
fn clipboard_file_mime_type(path: &Path) -> Option<&'static str> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let mime_type = match extension.as_str() {
"json" | "jsonl" | "ndjson" => "application/json",
"md" | "markdown" | "mdx" => "text/markdown",
"csv" => "text/csv",
"html" | "htm" => "text/html",
"xml" => "application/xml",
"svg" => "image/svg+xml",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
"gif" => "image/gif",
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"odt" => "application/vnd.oasis.opendocument.text",
"ods" => "application/vnd.oasis.opendocument.spreadsheet",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"ogg" | "oga" => "audio/ogg",
"flac" => "audio/flac",
"aac" => "audio/aac",
"txt" | "text" | "log" | "rst" | "tsv" | "yaml" | "yml" | "toml" | "ini" | "cfg"
| "conf" | "env" | "properties" | "css" | "scss" | "sass" | "less" | "js" | "jsx"
| "mjs" | "cjs" | "ts" | "tsx" | "py" | "pyi" | "ipynb" | "rb" | "php" | "go" | "rs"
| "java" | "kt" | "kts" | "scala" | "swift" | "c" | "h" | "cc" | "cpp" | "hpp" | "cxx"
| "cs" | "m" | "mm" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "lua" | "pl"
| "pm" | "r" | "jl" | "dart" | "vue" | "svelte" | "astro" | "sql" | "graphql" | "gql"
| "proto" | "tf" | "tfvars" | "gradle" | "dockerfile" | "makefile" | "cmake" | "diff"
| "patch" => "text/plain",
_ => return None,
};
Some(mime_type)
}
fn open_regular_clipboard_file(path: &Path) -> Option<File> {
let metadata = std::fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
.open(path)
.ok()
}
#[cfg(not(unix))]
{
File::open(path).ok()
}
}
fn read_clipboard_files(paths: Vec<PathBuf>) -> Result<Vec<NativeClipboardFile>, String> {
let mut remaining = MAX_CLIPBOARD_TOTAL_BYTES;
let mut files = Vec::new();
for path in paths.into_iter().take(MAX_CLIPBOARD_CANDIDATES) {
if remaining == 0 || files.len() >= MAX_CLIPBOARD_FILES {
break;
}
let Some(name) = path
.file_name()
.map(|value| value.to_string_lossy().into_owned())
else {
continue;
};
let Some(mime_type) = clipboard_file_mime_type(&path) else {
continue;
};
let Some(source) = open_regular_clipboard_file(&path) else {
continue;
};
let Ok(metadata) = source.metadata() else {
continue;
};
let limit = MAX_CLIPBOARD_SOURCE_BYTES.min(remaining);
if !metadata.is_file() || metadata.len() > limit {
continue;
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
if source.take(limit + 1).read_to_end(&mut bytes).is_err() || bytes.len() as u64 > limit {
continue;
}
remaining -= bytes.len() as u64;
files.push(NativeClipboardFile {
name,
mime_type: mime_type.to_string(),
base64: BASE64.encode(bytes),
});
}
if files.is_empty() {
return Err("Clipboard does not contain readable local files.".to_string());
}
Ok(files)
}
#[cfg(target_os = "linux")]
fn encode_clipboard_pixbuf(image: &gdk_pixbuf::Pixbuf) -> Result<Vec<u8>, String> {
validate_dimensions(image.width(), image.height())?;
let png = image
.save_to_bufferv("png", &[])
.map_err(|error| format!("Could not encode clipboard image: {error}"))?;
if png.is_empty() || png.len() > MAX_CLIPBOARD_PNG_BYTES {
return Err("Clipboard image encoding is empty or too large.".to_string());
}
Ok(png)
}
#[cfg(target_os = "linux")]
fn local_clipboard_path(uri: &str) -> Option<PathBuf> {
let (path, hostname) = glib::filename_from_uri(uri).ok()?;
hostname.is_none().then_some(path)
}
#[cfg(target_os = "linux")]
fn local_clipboard_paths_from_bytes(data: &[u8]) -> Vec<PathBuf> {
if data.len() > MAX_CLIPBOARD_URI_BYTES {
return Vec::new();
}
let Ok(text) = std::str::from_utf8(data) else {
return Vec::new();
};
text.lines()
.map(|line| {
line.trim_matches(|character: char| character.is_whitespace() || character == '\0')
})
.filter_map(local_clipboard_path)
.take(MAX_CLIPBOARD_CANDIDATES)
.collect()
}
#[cfg(target_os = "linux")]
fn read_gtk_clipboard_paths() -> Vec<PathBuf> {
let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD);
let mut paths: Vec<PathBuf> = clipboard
.wait_for_uris()
.into_iter()
.filter_map(|uri| local_clipboard_path(uri.as_str()))
.take(MAX_CLIPBOARD_CANDIDATES)
.collect();
for target in clipboard.wait_for_targets().unwrap_or_default() {
if paths.len() >= MAX_CLIPBOARD_CANDIDATES {
break;
}
if !target.name().to_ascii_lowercase().contains("copied-files") {
continue;
}
let Some(data) = clipboard.wait_for_contents(&target) else {
continue;
};
let length = data.length();
if length <= 0 || length as usize > MAX_CLIPBOARD_URI_BYTES {
continue;
}
for path in local_clipboard_paths_from_bytes(&data.data()) {
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths.truncate(MAX_CLIPBOARD_CANDIDATES);
paths
}
#[cfg(target_os = "linux")]
async fn native_clipboard_paths() -> Result<Vec<PathBuf>, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
glib::MainContext::default().invoke(move || {
let _ = tx.send(read_gtk_clipboard_paths());
});
rx.await
.map_err(|_| "Clipboard file reader stopped unexpectedly.".to_string())
}
#[cfg(not(target_os = "linux"))]
async fn native_clipboard_paths() -> Result<Vec<PathBuf>, String> {
tokio::task::spawn_blocking(|| {
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
let mut paths = clipboard
.get()
.file_list()
.map_err(|error| error.to_string())?;
paths.truncate(MAX_CLIPBOARD_CANDIDATES);
Ok(paths)
})
.await
.map_err(|_| "Clipboard file reader stopped unexpectedly.".to_string())?
}
#[tauri::command]
pub async fn read_native_clipboard_files(
window: tauri::WebviewWindow,
) -> Result<Vec<NativeClipboardFile>, String> {
crate::native_intents::ensure_main_window(&window)?;
let paths = native_clipboard_paths().await?;
tokio::task::spawn_blocking(move || read_clipboard_files(paths))
.await
.map_err(|_| "Clipboard file loader stopped unexpectedly.".to_string())?
}
#[cfg(target_os = "linux")]
fn read_gtk_clipboard_file_image() -> Result<gdk_pixbuf::Pixbuf, String> {
use std::os::fd::AsRawFd;
for path in read_gtk_clipboard_paths() {
let Some(source) = open_regular_clipboard_file(&path) else {
continue;
};
let Ok(metadata) = source.metadata() else {
continue;
};
if !metadata.is_file() || metadata.len() > MAX_CLIPBOARD_SOURCE_BYTES {
continue;
}
let descriptor_path = PathBuf::from(format!("/proc/self/fd/{}", source.as_raw_fd()));
let Some((_, width, height)) = gdk_pixbuf::Pixbuf::file_info(&descriptor_path) else {
continue;
};
if validate_dimensions(width, height).is_err() {
continue;
}
let Ok(image) = gdk_pixbuf::Pixbuf::from_file(&descriptor_path) else {
continue;
};
if validate_dimensions(image.width(), image.height()).is_ok() {
return Ok(image);
}
}
Err("Clipboard does not contain a readable image or local image file.".to_string())
}
#[cfg(target_os = "linux")]
fn read_gtk_clipboard_png() -> Result<Vec<u8>, String> {
let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD);
let png_target = gdk::Atom::intern("image/png");
if let Some(data) = clipboard.wait_for_contents(&png_target) {
let length = data.length();
if length <= 0 || length as usize > MAX_CLIPBOARD_PNG_BYTES {
return Err("Clipboard PNG data is empty or too large.".to_string());
}
let png = data.data();
validate_png_bytes(&png)?;
return Ok(png);
}
let image = match clipboard.wait_for_image() {
Some(image) => image,
None => read_gtk_clipboard_file_image()?,
};
encode_clipboard_pixbuf(&image)
}
#[cfg(target_os = "linux")]
#[tauri::command]
pub async fn read_native_clipboard_png(
window: tauri::WebviewWindow,
) -> Result<tauri::ipc::Response, String> {
crate::native_intents::ensure_main_window(&window)?;
let (tx, rx) = tokio::sync::oneshot::channel();
glib::MainContext::default().invoke(move || {
let _ = tx.send(read_gtk_clipboard_png());
});
let png = rx
.await
.map_err(|_| "Clipboard image reader stopped unexpectedly.".to_string())??;
Ok(tauri::ipc::Response::new(png))
}
#[cfg(not(target_os = "linux"))]
#[tauri::command]
pub async fn read_native_clipboard_png(
window: tauri::WebviewWindow,
) -> Result<tauri::ipc::Response, String> {
crate::native_intents::ensure_main_window(&window)?;
Err("Native PNG clipboard fallback is only available on Linux.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_dimensions_are_bounded() {
assert!(validate_dimensions(3840, 2160).is_ok());
assert!(validate_dimensions(0, 100).is_err());
assert!(validate_dimensions(8193, 100).is_err());
assert!(validate_dimensions(8192, 8192).is_err());
}
#[test]
fn clipboard_file_mime_types_cover_text_attachments() {
assert_eq!(
clipboard_file_mime_type(Path::new("data.json")),
Some("application/json")
);
assert_eq!(
clipboard_file_mime_type(Path::new("notes.md")),
Some("text/markdown")
);
assert_eq!(clipboard_file_mime_type(Path::new("unknown.bin")), None);
}
#[cfg(target_os = "linux")]
#[test]
fn clipboard_png_headers_are_bounded_before_decode() {
let mut png = vec![0; 24];
png[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
png[12..16].copy_from_slice(b"IHDR");
png[16..20].copy_from_slice(&1920_u32.to_be_bytes());
png[20..24].copy_from_slice(&1080_u32.to_be_bytes());
assert!(validate_png_bytes(&png).is_ok());
png[16..20].copy_from_slice(&9000_u32.to_be_bytes());
assert!(validate_png_bytes(&png).is_err());
}
#[test]
fn clipboard_file_reads_are_bounded() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("notes.md");
std::fs::write(&path, b"clipboard text").unwrap();
let oversized = directory.path().join("oversized.md");
File::create(&oversized)
.unwrap()
.set_len(MAX_CLIPBOARD_SOURCE_BYTES + 1)
.unwrap();
let files =
read_clipboard_files(vec![directory.path().to_path_buf(), oversized, path]).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "notes.md");
assert_eq!(files[0].mime_type, "text/markdown");
assert_eq!(BASE64.decode(&files[0].base64).unwrap(), b"clipboard text");
}
#[cfg(unix)]
#[test]
fn clipboard_file_reads_reject_symlinks() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target.md");
let link = directory.path().join("link.md");
std::fs::write(&target, b"clipboard text").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(open_regular_clipboard_file(&link).is_none());
assert!(read_clipboard_files(vec![link]).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn copied_file_targets_parse_local_uris() {
let paths = local_clipboard_paths_from_bytes(
b"copy\nfile:///tmp/pasted%20notes.md\nhttps://example.com/ignored.md\0",
);
assert_eq!(paths, vec![PathBuf::from("/tmp/pasted notes.md")]);
assert!(
local_clipboard_paths_from_bytes(&vec![b'x'; MAX_CLIPBOARD_URI_BYTES + 1]).is_empty()
);
}
#[cfg(target_os = "linux")]
#[test]
fn clipboard_file_uris_must_be_local() {
assert_eq!(
local_clipboard_path("file:///tmp/pasted%20image.png"),
Some(PathBuf::from("/tmp/pasted image.png"))
);
assert!(local_clipboard_path("file://remote/tmp/image.png").is_none());
assert!(local_clipboard_path("https://example.com/image.png").is_none());
}
}

View file

@ -20,10 +20,15 @@ THREAD_SIDEBAR = FRONTEND / "features/chat/thread-sidebar.tsx"
SHARED_COMPOSER = FRONTEND / "features/chat/shared-composer.tsx"
TITLEBAR = FRONTEND / "components/tauri/window-titlebar.tsx"
NATIVE_DIALOGS = REPO / "studio/src-tauri/src/native_file_dialogs.rs"
NATIVE_CLIPBOARD = REPO / "studio/src-tauri/src/native_clipboard.rs"
TAURI_MAIN = REPO / "studio/src-tauri/src/main.rs"
APP_PROVIDER = FRONTEND / "app/provider.tsx"
CLIPBOARD_FILES = FRONTEND / "features/chat/utils/clipboard-files.ts"
TAURI_CAPABILITIES = REPO / "studio/src-tauri/capabilities/default.json"
def test_file_actions_route_through_native_commands_only_in_tauri():
helper = NATIVE_FILES.read_text(encoding = "utf-8")
@ -98,6 +103,73 @@ def test_chat_exports_await_native_saves_and_markdown_uses_shared_helper():
assert "downloadFile(" in thread
def test_clipboard_file_paste_is_bounded_and_wired_to_both_composers():
helper = CLIPBOARD_FILES.read_text(encoding = "utf-8")
thread = THREAD.read_text(encoding = "utf-8")
shared_composer = SHARED_COMPOSER.read_text(encoding = "utf-8")
capabilities = TAURI_CAPABILITIES.read_text(encoding = "utf-8")
for contract in (
"clipboardData.files",
"clipboardData.items",
"item.getAsFile()",
"file.size > 0",
'clipboardData.getData("text/plain")',
"event.isTrusted",
"event.defaultPrevented",
'types.includes("files")',
'type.includes("uri-list")',
'"read_native_clipboard_files"',
"globalThis.atob(file.base64)",
"new File([bytes], file.name",
"MAX_CLIPBOARD_BYTES",
'import("@tauri-apps/plugin-clipboard-manager")',
"await readImage()",
"rgba.byteLength !== expectedRgbaBytes",
"await image.close()",
):
assert contract in helper
assert "addAttachmentOnPaste={false}" in thread
assert "onPaste={handleFilePaste}" in thread
assert "pasteClipboardFiles" in thread
assert "aui.composer().addAttachment(file)" in thread
assert "onPaste={handleFilePaste}" in shared_composer
assert "pasteClipboardFiles" in shared_composer
assert "addFiles(files)" in shared_composer
assert capabilities.count('"clipboard-manager:allow-read-image"') == 1
assert '"clipboard-manager:allow-read-text"' not in capabilities
def test_native_clipboard_bridge_is_bounded_and_registered():
native_clipboard = NATIVE_CLIPBOARD.read_text(encoding = "utf-8")
tauri_main = TAURI_MAIN.read_text(encoding = "utf-8")
for contract in (
"MAX_CLIPBOARD_FILES",
"MAX_CLIPBOARD_URI_BYTES",
"MAX_CLIPBOARD_TOTAL_BYTES",
"MAX_CLIPBOARD_SOURCE_BYTES",
"MAX_CLIPBOARD_RGBA_BYTES",
".take(limit + 1)",
".wait_for_uris()",
".wait_for_targets()",
'contains("copied-files")',
"open_regular_clipboard_file(&path)",
'"/proc/self/fd/{}"',
".wait_for_image()",
"glib::filename_from_uri",
"glib::MainContext::default().invoke",
"arboard::Clipboard::new()",
"BASE64.encode(bytes)",
"tauri::ipc::Response::new(png)",
):
assert contract in native_clipboard
assert "native_clipboard::read_native_clipboard_files" in tauri_main
assert "native_clipboard::read_native_clipboard_png" in tauri_main
def test_desktop_startup_waits_for_auth_without_intermediate_handoff():
source = APP_PROVIDER.read_text(encoding = "utf-8")