* Studio: add Anthropic fast_mode toggle + surface streaming refusals Fast mode (beta `fast-mode-2026-02-01`) lets Claude Opus 4.6 and 4.7 generate output tokens up to 2.5x faster at 6x standard Opus pricing. The toggle lives in Configuration → Provider when the selected Anthropic model is Opus 4.6 or 4.7 and is otherwise hidden. Backend gates the same prefixes a second time so a stale frontend cannot make Anthropic 400 the request, and the `fast-mode-2026-02-01` beta header is merged onto whatever other betas the request already needed (code-execution, compaction). Streaming refusals (`message_delta.delta.stop_reason="refusal"` on Claude 4 models) now surface a short user-facing notice in the assistant message before the translated OpenAI chunk emits the existing `finish_reason="content_filter"`. Previously the chat bubble truncated silently because the SSE stopped mid-stream with no visible explanation. Per the upstream docs the conversation must be reset before continuing, so the notice tells the user exactly that. Reference: - https://platform.claude.com/docs/en/build-with-claude/fast-mode - https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals Tests: - studio/backend/tests/test_anthropic_fast_mode_and_refusal.py (8 cases pinning fast_mode pass-through on 4.6/4.7, silent drop on Sonnet / Haiku / older Opus / None / False, and the refusal notice + finish reason on a synthetic refusal stream). * Studio: drop refused Anthropic turns from the next request Anthropic's streaming-refusal guidance says the refused assistant turn must be removed or updated before the next call -- otherwise the safety classifier keeps refusing. The PR only added a user-visible notice; the partial assistant output (plus the notice itself) still rode the next request via toOpenAIMessage. Tag the refusal turn with an HTML-comment sentinel emitted alongside the notice. The chat-adapter checks for that sentinel in toOpenAIMessage and returns null, so the refused turn is excluded from outboundMessages. The notice still renders in the transcript (HTML comments don't display), so users keep the explanation. * Studio: filter None finish_reason entries in test helper test_refusal_maps_to_content_filter expects only ['content_filter'] in the finish_reasons list, but the post-PR refusal path emits a user-visible content notice chunk first. Every _content_chunk carries 'finish_reason: None' by construction; the helper was appending those, so the assertion saw [None, 'content_filter'] instead of ['content_filter']. None is not a finish reason -- it's just mid-stream delta noise. Skip None values in _finish_reasons so the helper reflects what the test names actually claim to check. Same fix applies cleanly to the other helper usages (pause_turn test expects [] and the sibling stop test expects ['stop'], both unaffected). * Studio: cover Anthropic fast-mode edge cases Adds 19 cases on top of the 9 in test_anthropic_fast_mode_and_refusal. The base file pins the happy path; this file fills in the cliffs: * Dated-snapshot prefix matching: claude-opus-4-7-2026-02-01 and claude-opus-4-6-2026-02-01 still gate fast_mode through, while claude-opus-4-5-2025-08-01 and claude-sonnet-4-6-2026-02-01 do not. * Strict opt-in: a future claude-opus-4-8 or claude-opus-5 does NOT auto-enable fast_mode -- the prefix tuple must be bumped explicitly when a new family is whitelisted upstream. * Beta-header merge: fast_mode coexists with code-execution-2025-08-25 and compact-2026-01-12 in one comma-separated anthropic-beta header with no duplicates and no truncation. Pins the value to the exact fast-mode-2026-02-01 docs token so a typo would fail CI. * Non-destruction: fast_mode=None produces byte-identical outbound body and headers to the version that omits the argument entirely. Same for fast_mode=False. Guarantees the upgrade path is non-breaking on existing Anthropic streams. * Refusal stream ordering: the user-visible notice precedes the finish_reason chunk so a streaming UI paints text before flipping to content_filter. Refusal sentinel emitted exactly once. Notice rides a normal content delta chunk with finish_reason still null. Partial assistant deltas survive before the notice. * Provider-side refusal coverage: a refusal on Sonnet (not just Opus) still emits the notice + sentinel + content_filter mapping, since refusal handling is not gated on fast-mode capability. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Persist fastMode, drop refused user message on retry Two follow-ups on #5715: 1) sanitizeInferenceParams stripped fastMode. fastMode is in PERSISTED_INFERENCE_PARAM_KEYS but the storage sanitizer only kept numeric fields plus systemPrompt and trustRemoteCode, so the new toggle was silently dropped on reload and on the /api/chat/settings round-trip. Save it the same way trustRemoteCode is saved. 2) Refusal recovery now also drops the triggering user turn. Returning null from toOpenAIMessage on the assistant side left the user prompt that caused the refusal in the outbound history, so the very next request would re-trigger the same classifier. Anthropic's refusal-handling guidance is explicit on this: remove the refused turn AND the user message that triggered it before the next call. Implemented via a pre-pass that pops the trailing user message when an assistant carries the refusal sentinel. Typecheck clean. * Studio: out-of-band refusal signal + fast-mode prefix/usage/pricing fixes The text sentinel for the Anthropic refusal drop signal was spoofable: any assistant message containing the literal <!--studio:anthropic-refusal--> would prune the prior user + assistant pair on the next request. Move the signal onto a separate _toolEvent chunk that the chat adapter latches into assistant.metadata.custom.anthropicRefusal; assistant text can no longer control the pruner. Tighten the fast-mode model gate (backend + frontend) to require a "-" family boundary so claude-opus-4-70 / claude-opus-4-7b style IDs do not get speed: "fast" on a naive startswith match. Use survivingMessages for the image / audio attachment scan so a refused user turn does not gate or mis-attribute the next non-refused turn. Propagate Anthropic usage.speed onto the OpenAI-style usage chunk and apply the documented 6x fast-mode multiplier in the cost calculator (stacks with prompt-cache multipliers per the docs); expose the new multiplier on the pricing snapshot for the UI tooltip. Tests cover the tool-event chunk shape, the prefix-collision rejects, usage.speed propagation, the 6x pricing math, and that the visible refusal text carries no embedded sentinel. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Shorten fast-mode and refusal comments for PR #5715 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
164 lines
6.4 KiB
Python
164 lines
6.4 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 Anthropic fast-mode wiring and streaming refusal handling.
|
|
|
|
fast_mode=True on Opus 4.6/4.7 attaches the ``fast-mode-2026-02-01``
|
|
beta header and sets ``speed: "fast"``; unsupported models drop both.
|
|
Streaming ``stop_reason: "refusal"`` surfaces a user notice before the
|
|
``content_filter`` finish chunk.
|
|
https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import httpx
|
|
|
|
from core.inference import external_provider as ep_mod
|
|
from core.inference.external_provider import ExternalProviderClient
|
|
|
|
|
|
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 _empty_message_sse() -> bytes:
|
|
return (
|
|
b'event: message_start\ndata: {"type":"message_start","message":'
|
|
b'{"id":"m1","content":[],"model":"claude-opus-4-7","role":"assistant",'
|
|
b'"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\n'
|
|
b'event: message_delta\ndata: {"type":"message_delta",'
|
|
b'"delta":{"stop_reason":"end_turn"}}\n\n'
|
|
b'event: message_stop\ndata: {"type":"message_stop"}\n\n'
|
|
)
|
|
|
|
|
|
def _refusal_sse() -> bytes:
|
|
return (
|
|
b'event: message_start\ndata: {"type":"message_start","message":'
|
|
b'{"id":"m1","content":[],"model":"claude-opus-4-7","role":"assistant",'
|
|
b'"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\n'
|
|
b'event: content_block_start\ndata: {"type":"content_block_start",'
|
|
b'"index":0,"content_block":{"type":"text","text":""}}\n\n'
|
|
b'event: content_block_delta\ndata: {"type":"content_block_delta",'
|
|
b'"index":0,"delta":{"type":"text_delta","text":"Hello."}}\n\n'
|
|
b'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n'
|
|
b'event: message_delta\ndata: {"type":"message_delta",'
|
|
b'"delta":{"stop_reason":"refusal"}}\n\n'
|
|
b'event: message_stop\ndata: {"type":"message_stop"}\n\n'
|
|
)
|
|
|
|
|
|
def _capture(monkeypatch, sse: bytes = b"", **kwargs) -> tuple[dict, list[str]]:
|
|
"""Install a MockTransport, drive one streamed call, return body+lines."""
|
|
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 = sse or _empty_message_sse(),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
ep_mod,
|
|
"_http_client",
|
|
httpx.AsyncClient(transport = httpx.MockTransport(handler)),
|
|
)
|
|
|
|
out_lines: list[str] = []
|
|
|
|
async def run():
|
|
client = _make_client()
|
|
try:
|
|
async for line in client.stream_chat_completion(
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
model = kwargs.get("model", "claude-opus-4-7"),
|
|
temperature = 0.7,
|
|
top_p = 0.95,
|
|
max_tokens = 32,
|
|
fast_mode = kwargs.get("fast_mode"),
|
|
):
|
|
out_lines.append(line)
|
|
finally:
|
|
await client.close()
|
|
|
|
_drive(run())
|
|
return captured, out_lines
|
|
|
|
|
|
def test_fast_mode_attaches_beta_header_and_speed_on_opus_4_7(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-7")
|
|
assert cap["body"].get("speed") == "fast", cap["body"]
|
|
beta = cap["headers"].get("anthropic-beta", "")
|
|
assert "fast-mode-2026-02-01" in beta, beta
|
|
|
|
|
|
def test_fast_mode_attaches_beta_header_and_speed_on_opus_4_6(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-6")
|
|
assert cap["body"].get("speed") == "fast", cap["body"]
|
|
assert "fast-mode-2026-02-01" in cap["headers"].get("anthropic-beta", "")
|
|
|
|
|
|
def test_fast_mode_dropped_on_sonnet(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-sonnet-4-6")
|
|
assert "speed" not in cap["body"], cap["body"]
|
|
assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "")
|
|
|
|
|
|
def test_fast_mode_dropped_on_haiku(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-haiku-4-5")
|
|
assert "speed" not in cap["body"], cap["body"]
|
|
assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "")
|
|
|
|
|
|
def test_fast_mode_dropped_on_older_opus(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-5")
|
|
assert "speed" not in cap["body"], cap["body"]
|
|
|
|
|
|
def test_fast_mode_false_does_not_attach_header_or_field(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = False)
|
|
assert "speed" not in cap["body"], cap["body"]
|
|
assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "")
|
|
|
|
|
|
def test_fast_mode_none_does_not_attach_header_or_field(monkeypatch):
|
|
cap, _ = _capture(monkeypatch, fast_mode = None)
|
|
assert "speed" not in cap["body"], cap["body"]
|
|
assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "")
|
|
|
|
|
|
def test_refusal_emits_user_facing_notice_and_content_filter_finish(monkeypatch):
|
|
_, lines = _capture(monkeypatch, sse = _refusal_sse())
|
|
body = "\n".join(lines)
|
|
# User-visible refusal notice.
|
|
assert "stopped by Anthropic's safety classifier" in body, body
|
|
# OpenAI-spec finish_reason mapping.
|
|
assert '"finish_reason": "content_filter"' in body, body
|
|
# Original deltas preserved before the refusal supplement.
|
|
assert "Hello." in body, body
|
|
|
|
|
|
def test_refusal_emits_tool_event_for_chat_adapter_drop(monkeypatch):
|
|
"""Refused turns emit an out-of-band `_toolEvent` that the chat-adapter
|
|
latches into assistant `metadata.custom.anthropicRefusal`, driving
|
|
the next-request prune. Tool event (not text) prevents spoofing.
|
|
"""
|
|
_, lines = _capture(monkeypatch, sse = _refusal_sse())
|
|
body = "\n".join(lines)
|
|
assert '"_toolEvent": {"type": "anthropic_refusal"}' in body, body
|
|
# Visible refusal text must not embed a sentinel that could spoof
|
|
# a context reset if echoed by another assistant message.
|
|
assert "studio:anthropic-refusal" not in body, body
|