Files
unslothai--unsloth/studio/backend/tests/test_anthropic_compaction.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

605 lines
22 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
"""Unit tests for Anthropic server-side context compaction wiring.
Compaction is a beta (header ``compact-2026-01-12``) gated to Opus 4.6/4.7,
Sonnet 4.6, and Mythos preview. When enabled, Studio attaches
``context_management.edits[{type:"compact_20260112", trigger:{type:"input_tokens",
value:N}}]``; the 50k-token minimum is clamped up so the request doesn't 400.
Pins body shape per model, beta header merge with code-execution, threshold
clamping, and silent no-op on unsupported models.
"""
import asyncio
import json
import httpx
import pytest
from core.inference import external_provider as ep_mod
from core.inference.external_provider import (
ExternalProviderClient,
_anthropic_supports_compaction,
)
def _drive(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_client() -> ExternalProviderClient:
return ExternalProviderClient(
provider_type = "anthropic",
base_url = "https://api.anthropic.com/v1",
api_key = "sk-ant-test",
)
def _capture(
monkeypatch,
model: str,
threshold,
tools = None,
) -> dict:
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
captured["headers"] = dict(request.headers)
return httpx.Response(
200,
content = b'event: message_stop\ndata: {"type": "message_stop"}\n\n',
headers = {"content-type": "text/event-stream"},
)
monkeypatch.setattr(
ep_mod,
"_http_client",
httpx.AsyncClient(transport = httpx.MockTransport(handler)),
)
async def run():
client = _make_client()
async for _ in client.stream_chat_completion(
messages = [{"role": "user", "content": "hi"}],
model = model,
temperature = 0.7,
top_p = 0.95,
max_tokens = 32,
enabled_tools = tools,
compaction_threshold = threshold,
):
pass
await client.close()
_drive(run())
return captured
# ── support gate matches the doc table ───────────────────────────────
@pytest.mark.parametrize(
"model, supported",
[
("claude-opus-4-7", True),
("claude-opus-4-6", True),
("claude-sonnet-4-6", True),
("claude-mythos-preview", True),
# NOT supported per the docs.
("claude-opus-4-5-20251101", False),
("claude-sonnet-4-5-20250929", False),
("claude-haiku-4-5-20251001", False),
("claude-opus-4-1-20250805", False),
("claude-opus-4-20250514", False),
("claude-sonnet-4-20250514", False),
("claude-3-5-sonnet-20241022", False),
],
)
def test_supports_compaction_gate(model, supported):
assert _anthropic_supports_compaction(model) is supported
# ── outbound shape on supported model ────────────────────────────────
def test_supported_model_attaches_compaction_block_and_beta(monkeypatch):
captured = _capture(monkeypatch, "claude-opus-4-7", 150_000)
cm = captured["body"].get("context_management")
assert cm == {
"edits": [
{
"type": "compact_20260112",
"trigger": {"type": "input_tokens", "value": 150_000},
}
]
}, cm
assert "compact-2026-01-12" in captured["headers"].get("anthropic-beta", "")
def test_threshold_clamped_to_50k_minimum(monkeypatch):
# Below-min values get clamped UP so we don't 400 upstream.
captured = _capture(monkeypatch, "claude-opus-4-7", 60_000)
assert captured["body"]["context_management"]["edits"][0]["trigger"]["value"] == 60_000
captured = _capture(monkeypatch, "claude-opus-4-7", 1)
assert captured["body"]["context_management"]["edits"][0]["trigger"]["value"] == 50_000
# ── beta header merge with code execution ────────────────────────────
def test_compaction_beta_merges_with_code_execution_beta(monkeypatch):
captured = _capture(
monkeypatch,
"claude-opus-4-7",
150_000,
tools = ["code_execution"],
)
beta = captured["headers"].get("anthropic-beta", "")
assert "code-execution-2025-08-25" in beta
assert "compact-2026-01-12" in beta
# ── silent no-op on unsupported model ────────────────────────────────
def test_unsupported_model_silently_drops_compaction(monkeypatch):
captured = _capture(monkeypatch, "claude-haiku-4-5-20251001", 150_000)
assert "context_management" not in captured["body"]
# The beta header must not carry compact-2026-01-12 either.
assert "compact-2026-01-12" not in captured["headers"].get("anthropic-beta", "")
# ── omitted threshold leaves body untouched ─────────────────────────
def test_omitted_threshold_no_body_field(monkeypatch):
captured = _capture(monkeypatch, "claude-opus-4-7", None)
assert "context_management" not in captured["body"]
assert "compact-2026-01-12" not in captured["headers"].get("anthropic-beta", "")
# ── ChatCompletionRequest schema accepts sub-50k threshold ──────────
def test_chat_completion_request_accepts_sub_50k_compaction_threshold():
# ge=50_000 on the field would 422 before the in-helper clamp fires; the
# schema must accept any positive int and let _stream_anthropic clamp up.
from models.inference import ChatCompletionRequest
req = ChatCompletionRequest.model_validate(
{
"model": "default",
"messages": [{"role": "user", "content": "hi"}],
"compaction_threshold": 1,
}
)
assert req.compaction_threshold == 1
req = ChatCompletionRequest.model_validate(
{
"model": "default",
"messages": [{"role": "user", "content": "hi"}],
"compaction_threshold": 49_999,
}
)
assert req.compaction_threshold == 49_999
# Non-positive values are still rejected so blank-string posts
# don't sneak through.
with pytest.raises(Exception):
ChatCompletionRequest.model_validate(
{
"model": "default",
"messages": [{"role": "user", "content": "hi"}],
"compaction_threshold": 0,
}
)
# ── usage.iterations[] surfaces compaction tokens ──────────────────
def test_message_delta_iterations_array_aggregates_compaction_tokens(monkeypatch, capsys):
# On mid-stream compaction the message_delta usage carries
# `iterations: [{type:"compaction", ...}, ...]`. Top-level tokens only cover
# the `message` iteration, so the helper folds compaction totals into
# last_usage and surfaces them in the closing summary log.
def http_handler(request: httpx.Request) -> httpx.Response:
body = (
b"event: message_start\n"
b'data: {"type":"message_start","message":{"usage":{"input_tokens":23000,"output_tokens":0}}}\n\n'
b"event: message_delta\n"
b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},'
b'"usage":{"input_tokens":23000,"output_tokens":1000,'
b'"iterations":['
b'{"type":"compaction","input_tokens":180000,"output_tokens":3500},'
b'{"type":"message","input_tokens":23000,"output_tokens":1000}'
b"]}}\n\n"
b"event: message_stop\n"
b'data: {"type":"message_stop"}\n\n'
)
return httpx.Response(
200,
content = body,
headers = {"content-type": "text/event-stream"},
)
monkeypatch.setattr(
ep_mod,
"_http_client",
httpx.AsyncClient(transport = httpx.MockTransport(http_handler)),
)
async def run():
client = _make_client()
async for _ in client.stream_chat_completion(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 32,
compaction_threshold = 150_000,
):
pass
await client.close()
_drive(run())
# structlog renders the closing summary onto stdout; check the rendered line.
out = capsys.readouterr().out
summary = next(
(line for line in out.splitlines() if "Anthropic stream complete" in line),
"",
)
assert "compaction_input_tokens=180000" in summary, summary
assert "compaction_output_tokens=3500" in summary, summary
def test_message_delta_no_iterations_leaves_compaction_keys_unset(monkeypatch, capsys):
# Re-applying a prior compaction block emits no fresh iterations array;
# the helper must not invent compaction keys (would double-bill).
def http_handler(request: httpx.Request) -> httpx.Response:
body = (
b"event: message_delta\n"
b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},'
b'"usage":{"input_tokens":1234,"output_tokens":5}}\n\n'
b"event: message_stop\n"
b'data: {"type":"message_stop"}\n\n'
)
return httpx.Response(
200,
content = body,
headers = {"content-type": "text/event-stream"},
)
monkeypatch.setattr(
ep_mod,
"_http_client",
httpx.AsyncClient(transport = httpx.MockTransport(http_handler)),
)
async def run():
client = _make_client()
async for _ in client.stream_chat_completion(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 32,
compaction_threshold = 150_000,
):
pass
await client.close()
_drive(run())
out = capsys.readouterr().out
summary = next(
(line for line in out.splitlines() if "Anthropic stream complete" in line),
"",
)
assert "compaction_input_tokens=None" in summary, summary
assert "compaction_output_tokens=None" in summary, summary
# ── compaction block round-trip (Codex P1) ──────────────────────────
def _async_collect(agen):
async def run():
out = []
async for line in agen:
out.append(line)
return out
return _drive(run())
def test_compaction_block_emitted_as_tool_event(monkeypatch):
# When Anthropic compacts during a turn, the response carries a
# `{type:"compaction", content:"<summary>"}` block. The translator must surface
# it so the chat-adapter persists it; else the next turn loses state and re-compacts.
def http_handler(request: httpx.Request) -> httpx.Response:
# Compaction blocks arrive as a content_block_start with type:"compaction",
# with the summary on the start event AND/OR streamed via text_delta on the
# same index. Test the streamed-delta path (harder case).
body = (
b"event: message_start\n"
b'data: {"type":"message_start","message":{"usage":{}}}\n\n'
b"event: content_block_start\n"
b'data: {"type":"content_block_start","index":0,'
b'"content_block":{"type":"compaction","content":""}}\n\n'
b"event: content_block_delta\n"
b'data: {"type":"content_block_delta","index":0,'
b'"delta":{"type":"text_delta","text":"Summary so far: "}}\n\n'
b"event: content_block_delta\n"
b'data: {"type":"content_block_delta","index":0,'
b'"delta":{"type":"text_delta","text":"user asked about caching."}}\n\n'
b"event: content_block_stop\n"
b'data: {"type":"content_block_stop","index":0}\n\n'
b"event: content_block_start\n"
b'data: {"type":"content_block_start","index":1,'
b'"content_block":{"type":"text","text":""}}\n\n'
b"event: content_block_delta\n"
b'data: {"type":"content_block_delta","index":1,'
b'"delta":{"type":"text_delta","text":"Here is my answer."}}\n\n'
b"event: content_block_stop\n"
b'data: {"type":"content_block_stop","index":1}\n\n'
b"event: message_delta\n"
b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},'
b'"usage":{"input_tokens":100,"output_tokens":10}}\n\n'
b"event: message_stop\n"
b'data: {"type":"message_stop"}\n\n'
)
return httpx.Response(
200,
content = body,
headers = {"content-type": "text/event-stream"},
)
monkeypatch.setattr(
ep_mod,
"_http_client",
httpx.AsyncClient(transport = httpx.MockTransport(http_handler)),
)
client = _make_client()
lines = _async_collect(
client._stream_anthropic(
messages = [{"role": "user", "content": "hi"}],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 1024,
compaction_threshold = 150_000,
)
)
_drive(client.close())
# Pull tool_events out of the SSE stream and check for the
# compaction_block payload.
events = []
for line in lines:
if not line.startswith("data:"):
continue
raw = line[len("data:") :].strip()
if not raw or raw == "[DONE]":
continue
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
continue
# tool_event payloads ride inside the chunk as JSON; just match the marker.
if "compaction_block" in raw:
events.append(raw)
assert events, f"no compaction_block tool event found in {lines}"
# The summary text must come through intact.
payload = events[0]
assert "Summary so far: user asked about caching." in payload, payload
# The user-visible content stream must NOT carry the compaction
# summary -- only the assistant prose ("Here is my answer.").
content_text = ""
for line in lines:
if not line.startswith("data:"):
continue
raw = line[len("data:") :].strip()
if not raw or raw == "[DONE]":
continue
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
continue
if parsed.get("object") != "chat.completion.chunk":
continue
for choice in parsed.get("choices") or []:
delta = choice.get("delta") or {}
chunk = delta.get("content")
if isinstance(chunk, str):
content_text += chunk
assert "Summary so far" not in content_text, content_text
assert "Here is my answer." in content_text, content_text
def test_compaction_block_round_trips_through_outbound_messages(monkeypatch):
# The next turn's outbound body must forward a persisted
# {type:"compaction", content:"..."} block verbatim so the API recognises the state.
captured: dict = {}
def http_handler(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
content = b'event: message_stop\ndata: {"type": "message_stop"}\n\n',
headers = {"content-type": "text/event-stream"},
)
monkeypatch.setattr(
ep_mod,
"_http_client",
httpx.AsyncClient(transport = httpx.MockTransport(http_handler)),
)
client = _make_client()
async def run():
async for _ in client.stream_chat_completion(
messages = [
{"role": "user", "content": "turn 1 question"},
{
"role": "assistant",
"content": [
{
"type": "compaction",
"content": "PRIOR SUMMARY: user asked about caching.",
},
{"type": "text", "text": "Sure, here's an answer."},
],
},
{"role": "user", "content": "turn 2 follow-up"},
],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 32,
compaction_threshold = 150_000,
):
pass
_drive(run())
_drive(client.close())
msgs = captured["body"]["messages"]
# The assistant turn must include the compaction block on the wire.
assistant = next((m for m in msgs if m["role"] == "assistant"), None)
assert assistant is not None, msgs
parts = assistant["content"]
types = [p.get("type") for p in parts if isinstance(p, dict)]
assert "compaction" in types, parts
compaction_part = next(p for p in parts if p.get("type") == "compaction")
assert compaction_part["content"] == "PRIOR SUMMARY: user asked about caching."
def test_compaction_content_part_accepted_by_chat_message_schema():
# Without the Pydantic Tag the discriminated Union would 422 at parse time.
from models.inference import ChatMessage
msg = ChatMessage.model_validate(
{
"role": "assistant",
"content": [
{"type": "compaction", "content": "summary text"},
{"type": "text", "text": "answer prose"},
],
}
)
assert isinstance(msg.content, list)
assert msg.content[0].type == "compaction"
assert msg.content[0].content == "summary text"
assert msg.content[1].type == "text"
def test_build_external_messages_passes_compaction_for_anthropic_only():
# Compaction is Anthropic-only; the builder must gate it on
# provider_type=="anthropic" since others 400 on the unknown content type.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "assistant",
"content": [
{"type": "compaction", "content": "prior summary"},
{"type": "text", "text": "answer"},
],
}
)
]
out = _build_external_messages(msgs, supports_vision = True, provider_type = "anthropic")
assert len(out) == 1
parts = out[0]["content"]
assert parts[0] == {"type": "compaction", "content": "prior summary"}
assert parts[1] == {"type": "text", "text": "answer"}
def test_build_external_messages_strips_compaction_for_non_anthropic_providers():
# A provider switch or reused history can hand compaction blocks to a
# non-Anthropic provider whose validator rejects the unknown type, so the
# builder must strip the part for every non-anthropic provider.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "assistant",
"content": [
{"type": "compaction", "content": "prior summary"},
{"type": "text", "text": "answer"},
],
}
)
]
for provider in ("openai", "deepseek", "mistral", "gemini", "kimi", "openrouter"):
out = _build_external_messages(msgs, supports_vision = True, provider_type = provider)
assert len(out) == 1, (provider, out)
parts = out[0]["content"]
types = [p.get("type") for p in parts if isinstance(p, dict)]
assert "compaction" not in types, (provider, parts)
# Text part survives.
assert {"type": "text", "text": "answer"} in parts, (provider, parts)
def test_build_external_messages_strips_compaction_when_provider_type_unknown():
# Defensive: provider_type=None (legacy path) must also strip the part.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "assistant",
"content": [
{"type": "compaction", "content": "prior summary"},
{"type": "text", "text": "answer"},
],
}
)
]
out = _build_external_messages(msgs, supports_vision = True)
parts = out[0]["content"]
types = [p.get("type") for p in parts if isinstance(p, dict)]
assert "compaction" not in types, parts
def test_build_external_messages_non_vision_anthropic_keeps_compaction():
# Defensive: gate the non-vision branch by provider_type too, so future
# config changes don't drop compaction for Anthropic.
from models.inference import ChatMessage
from routes.inference import _build_external_messages
msgs = [
ChatMessage.model_validate(
{
"role": "assistant",
"content": [
{"type": "compaction", "content": "prior summary"},
{"type": "text", "text": "answer"},
],
}
)
]
out = _build_external_messages(msgs, supports_vision = False, provider_type = "anthropic")
parts = out[0]["content"]
assert {"type": "compaction", "content": "prior summary"} in parts
# Non-anthropic + non-vision -> compaction stripped, text collapsed
# back to a string.
out2 = _build_external_messages(msgs, supports_vision = False, provider_type = "deepseek")
assert out2[0]["content"] == "answer", out2