e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
278 lines
9.6 KiB
Python
278 lines
9.6 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the opt-in ``context_overflow="truncate_middle"`` passthrough policy.
|
|
|
|
On ``exceed_context_size_error`` the passthrough drops middle turn-groups and
|
|
retries inside the real window instead of surfacing a fatal 400. Truncation
|
|
keeps the system prompt, the first turn, and recent turns, and never orphans
|
|
a tool result from its tool_calls turn. Also covers ``/v1/models`` exposing
|
|
the real post-readback context window.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
from routes.inference import (
|
|
_apply_overflow_truncation,
|
|
_clip_long_contents,
|
|
_CLIP_MARKER,
|
|
_estimate_message_tokens,
|
|
_openai_model_objects,
|
|
_overflow_truncation_requested,
|
|
_parse_overflow_counts,
|
|
_truncate_middle_messages,
|
|
)
|
|
import routes.inference as routes_mod
|
|
|
|
|
|
# Nick's actual error body from the Discord report logs.
|
|
_NICK_ERROR = (
|
|
'{"detail":"llama-server error: {\\"error\\":{\\"code\\":400,'
|
|
'\\"message\\":\\"request (70494 tokens) exceeds the available context size '
|
|
'(67584 tokens), try increasing it\\",\\"type\\":\\"exceed_context_size_error\\",'
|
|
'\\"n_prompt_tokens\\":70494,\\"n_ctx\\":67584}}"}'
|
|
)
|
|
|
|
|
|
def _tool_turn(i: int, result_chars: int = 400) -> list[dict]:
|
|
"""An assistant tool_calls turn paired with its tool result."""
|
|
return [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": f"call_{i}",
|
|
"type": "function",
|
|
"function": {"name": "read", "arguments": f'{{"filePath":"/f{i}"}}'},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": f"call_{i}", "content": "x" * result_chars},
|
|
]
|
|
|
|
|
|
def _conversation(n_tool_turns: int = 12) -> list[dict]:
|
|
msgs = [
|
|
{"role": "system", "content": "You are an agent." * 20},
|
|
{"role": "user", "content": "Do the big task." * 20},
|
|
]
|
|
for i in range(n_tool_turns):
|
|
msgs.extend(_tool_turn(i))
|
|
msgs.append({"role": "assistant", "content": "halfway summary"})
|
|
msgs.append({"role": "user", "content": "keep going"})
|
|
return msgs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_overflow_counts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_overflow_counts_nick_error():
|
|
assert _parse_overflow_counts(_NICK_ERROR) == (70494, 67584)
|
|
|
|
|
|
def test_parse_overflow_counts_missing_fields():
|
|
assert _parse_overflow_counts('{"error":"something else"}') is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _truncate_middle_messages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_truncation_drops_middle_keeps_anchors():
|
|
msgs = _conversation()
|
|
new, dropped = _truncate_middle_messages(msgs, keep_ratio = 0.5)
|
|
assert dropped > 0
|
|
assert len(new) == len(msgs) - dropped
|
|
# System prompt and task anchor survive.
|
|
assert new[0]["role"] == "system"
|
|
assert new[1] == msgs[1]
|
|
# The most recent turns survive verbatim.
|
|
assert new[-1] == msgs[-1]
|
|
assert new[-2] == msgs[-2]
|
|
|
|
|
|
def test_truncation_never_orphans_tool_results():
|
|
msgs = _conversation()
|
|
new, dropped = _truncate_middle_messages(msgs, keep_ratio = 0.4)
|
|
assert dropped > 0
|
|
surviving_call_ids = {
|
|
tc["id"] for m in new if m.get("role") == "assistant" for tc in (m.get("tool_calls") or [])
|
|
}
|
|
for m in new:
|
|
if m.get("role") == "tool":
|
|
assert m["tool_call_id"] in surviving_call_ids
|
|
|
|
|
|
def test_truncation_reduces_estimated_size_toward_target():
|
|
msgs = _conversation()
|
|
total = sum(_estimate_message_tokens(m) for m in msgs)
|
|
new, dropped = _truncate_middle_messages(msgs, keep_ratio = 0.5)
|
|
new_total = sum(_estimate_message_tokens(m) for m in new)
|
|
assert dropped > 0
|
|
assert new_total < total
|
|
# Should land at or below the requested share, modulo one whole group.
|
|
biggest_group = max(
|
|
_estimate_message_tokens(a) + _estimate_message_tokens(b)
|
|
for a, b in zip(msgs[2:-2:2], msgs[3:-2:2])
|
|
)
|
|
assert new_total <= int(total * 0.5) + biggest_group
|
|
|
|
|
|
def test_truncation_noop_when_keep_ratio_full():
|
|
msgs = _conversation()
|
|
new, dropped = _truncate_middle_messages(msgs, keep_ratio = 1.0)
|
|
assert dropped == 0
|
|
assert new == msgs
|
|
|
|
|
|
def test_truncation_noop_when_only_protected_turns_remain():
|
|
msgs = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "task"},
|
|
*_tool_turn(0),
|
|
{"role": "user", "content": "latest"},
|
|
]
|
|
new, dropped = _truncate_middle_messages(msgs, keep_ratio = 0.1)
|
|
assert dropped == 0
|
|
assert new == msgs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _apply_overflow_truncation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_apply_overflow_truncation_mutates_body_and_clamps_max_tokens():
|
|
body = {"messages": _conversation(), "max_tokens": 32000}
|
|
assert _apply_overflow_truncation(body, _NICK_ERROR) is True
|
|
assert len(body["messages"]) < len(_conversation())
|
|
# Generation headroom: max_tokens clamped to the non-prompt share of n_ctx.
|
|
assert body["max_tokens"] <= max(1024, int(67584 * 0.25))
|
|
|
|
|
|
def test_apply_overflow_truncation_returns_false_when_nothing_droppable():
|
|
body = {
|
|
"messages": [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "task"},
|
|
{"role": "user", "content": "latest"},
|
|
],
|
|
"max_tokens": 32000,
|
|
}
|
|
assert _apply_overflow_truncation(body, _NICK_ERROR) is False
|
|
|
|
|
|
def test_apply_overflow_truncation_clips_giant_protected_tool_results():
|
|
"""One giant burst (few turn-groups, all protected) must still shrink:
|
|
stage 2 clips oversized tool contents instead of giving up."""
|
|
msgs = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "task"},
|
|
*_tool_turn(0, result_chars = 60000),
|
|
*_tool_turn(1, result_chars = 60000),
|
|
]
|
|
body = {"messages": msgs, "max_tokens": 32000}
|
|
n_before = len(msgs)
|
|
assert _apply_overflow_truncation(body, _NICK_ERROR) is True
|
|
# No message disappeared (pairing intact), but contents were clipped.
|
|
assert len(body["messages"]) == n_before
|
|
clipped = [m for m in body["messages"] if _CLIP_MARKER in str(m.get("content"))]
|
|
assert clipped, "expected at least one clipped tool result"
|
|
surviving_call_ids = {
|
|
tc["id"]
|
|
for m in body["messages"]
|
|
if m.get("role") == "assistant"
|
|
for tc in (m.get("tool_calls") or [])
|
|
}
|
|
for m in body["messages"]:
|
|
if m.get("role") == "tool":
|
|
assert m["tool_call_id"] in surviving_call_ids
|
|
|
|
|
|
def test_clip_long_contents_reaches_target_and_keeps_structure():
|
|
msgs = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "task"},
|
|
*_tool_turn(0, result_chars = 40000),
|
|
{"role": "user", "content": "latest question"},
|
|
]
|
|
total = sum(_estimate_message_tokens(m) for m in msgs)
|
|
clipped = _clip_long_contents(msgs, target_est = total // 4)
|
|
assert clipped >= 1
|
|
assert sum(_estimate_message_tokens(m) for m in msgs) <= total // 4
|
|
# Roles and count unchanged; the short final user message untouched.
|
|
assert [m["role"] for m in msgs] == ["system", "user", "assistant", "tool", "user"]
|
|
assert msgs[-1]["content"] == "latest question"
|
|
|
|
|
|
def test_overflow_truncation_requested_reads_field(monkeypatch):
|
|
monkeypatch.delenv("UNSLOTH_CONTEXT_OVERFLOW", raising = False)
|
|
|
|
class _P:
|
|
context_overflow = "truncate_middle"
|
|
|
|
class _Q:
|
|
context_overflow = None
|
|
|
|
assert _overflow_truncation_requested(_P()) is True
|
|
assert _overflow_truncation_requested(_Q()) is False
|
|
assert _overflow_truncation_requested(object()) is False
|
|
|
|
|
|
def test_overflow_truncation_server_default_env(monkeypatch):
|
|
"""UNSLOTH_CONTEXT_OVERFLOW enables the policy for clients that cannot
|
|
send custom body fields; an explicit per-request 'error' still wins."""
|
|
|
|
class _Unset:
|
|
context_overflow = None
|
|
|
|
class _ExplicitError:
|
|
context_overflow = "error"
|
|
|
|
monkeypatch.setenv("UNSLOTH_CONTEXT_OVERFLOW", "truncate_middle")
|
|
assert _overflow_truncation_requested(_Unset()) is True
|
|
assert _overflow_truncation_requested(_ExplicitError()) is False
|
|
monkeypatch.setenv("UNSLOTH_CONTEXT_OVERFLOW", "error")
|
|
assert _overflow_truncation_requested(_Unset()) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /v1/models context metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeLlamaBackend:
|
|
is_loaded = True
|
|
model_identifier = "unsloth/Qwen3.6-27B-GGUF"
|
|
context_length = 67584
|
|
max_context_length = 262144
|
|
|
|
|
|
class _FakeEmptyBackend:
|
|
active_model_name = None
|
|
|
|
|
|
def test_v1_models_exposes_real_context_window(monkeypatch):
|
|
monkeypatch.setattr(routes_mod, "get_llama_cpp_backend", lambda: _FakeLlamaBackend())
|
|
monkeypatch.setattr(routes_mod, "get_inference_backend", lambda: _FakeEmptyBackend())
|
|
models = _openai_model_objects()
|
|
assert len(models) == 1
|
|
entry = models[0]
|
|
assert entry["id"] == "unsloth/Qwen3.6-27B-GGUF"
|
|
# The REAL (post /props readback) window, not the requested one.
|
|
assert entry["context_length"] == 67584
|
|
assert entry["max_context_length"] == 262144
|