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
605 lines
22 KiB
Python
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
|