unsloth/studio/backend/tests/test_anthropic_cache_ttl.py
Daniel Han b8dde0a835
Studio: support Anthropic 1h cache TTL via prompt_cache_ttl (#5685)
* Studio: support Anthropic 1h cache TTL via prompt_cache_ttl field

Anthropic exposes two ephemeral cache pools per request: the default
5-minute pool, and a 1-hour pool selected by attaching `ttl:"1h"` to
the `cache_control` marker. 1h writes are billed at 2x base input vs
1.25x for 5m, but reads stay at 0.1x for both, so a single extra read
landing more than 5 minutes after the write pays off the premium.

Studio hardcoded the 5m pool via `cache_control: {type:"ephemeral"}`
on both breakpoints. For chats with multi-minute idle gaps (people
juggling tabs, long-running tool calls between turns), the cache
expires before the next turn and every read becomes a cache_creation,
not a cache_read -- exactly the case where the 1h pool wins.

Changes:

- Add `prompt_cache_ttl: Optional[Literal["5m", "1h"]]` to
  ChatCompletionRequest. Default (None) preserves today's 5m behavior.
- Thread through `routes/inference.py` ->
  `stream_chat_completion` -> `_stream_anthropic`.
- Build a shared `cache_marker` dict in `_stream_anthropic`; attach
  `ttl` only when the request asks for one of the two valid values.
  Unknown TTL strings are silently dropped to avoid sending malformed
  markers (the upstream API would 400).
- Apply the same marker to both existing breakpoints (system block at
  line 1175 and the latest-message tail at line 1198 / 1213) so the
  pool selection is consistent across the whole prefix.
- Add `test_anthropic_cache_ttl.py` with 11 parametrized cases
  pinning the outbound body shape: omitted -> default marker;
  explicit `5m`/`1h` -> ttl field set; unknown values dropped;
  caching off -> no markers at all.

Verified upstream that `cache_control: {type:"ephemeral", ttl:"1h"}`
is accepted by the Anthropic API today; no beta header required.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Relax prompt_cache_ttl to Optional[str] (Codex P1)

Declaring `prompt_cache_ttl` as `Optional[Literal["5m", "1h"]]` made
FastAPI/Pydantic 422 the request before _stream_anthropic could even
see the field. The whole point of the downstream drop-unknown-values
behaviour was to keep a stale frontend from crashing the request;
the strict Literal at the request layer defeated that.

Loosen the schema to Optional[str]; the existing in-helper guard
already restricts forwarded values to {"5m", "1h"} (everything else
is silently dropped). Test suite stays unchanged -- the bogus-value
cases in test_anthropic_cache_ttl.py already pass arbitrary strings
through and assert they are dropped before the wire.

* Address review: confirm extended-cache-ttl beta header is GA

Reviewer asked whether the 1h cache TTL still requires the
`extended-cache-ttl-2025-04-11` anthropic-beta header. Investigated:

- Live-tested api.anthropic.com on claude-opus-4-7 (2026-05-22)
  with cache_control={type:"ephemeral", ttl:"1h"} and NO beta
  header. Got status 200 and ephemeral_1h_input_tokens populated
  on the create turn, plus cache_read_input_tokens populated on
  the reuse turn.
- Cross-checked the current prompt-caching docs: no mention of
  any beta header on the 1h TTL path.

Conclusion: the gate has been promoted to GA. The code already
does not send the beta header (the cache_marker dict only carries
`type`/`ttl`), so no wire change is needed. Pinned the contract
with two regression tests that assert the header is NOT on the
outbound request, and added a docstring note explaining the
investigation outcome so a future reader does not re-add it.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-22 06:03:32 -07:00

201 lines
6.9 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 the prompt_cache_ttl threading on the Anthropic path.
Anthropic accepts an optional ``ttl`` on each ``cache_control`` marker:
the default is the 5-minute ephemeral pool; ``ttl:"1h"`` writes into
the 1-hour pool instead. The 1h pool is the right pick when
conversations span multiple short bursts more than 5 minutes apart --
1h writes are billed at 2x base input vs 1.25x for 5m, but reads stay
at 0.1x for both, so one extra read pays off the premium.
These tests pin the outbound body shape: when prompt_cache_ttl="1h"
both cache_control markers carry ``ttl:"1h"``; default omits the field
entirely so the 5m pool is used; garbage values are silently dropped.
"""
import asyncio
import json
import httpx
import pytest
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 _capture(monkeypatch, ttl = 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\n" b'data: {"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": "system", "content": "Be brief."},
{"role": "user", "content": "hi"},
],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 32,
enable_prompt_caching = True,
prompt_cache_ttl = ttl,
):
pass
await client.close()
_drive(run())
return captured
def _cache_controls(body: dict) -> list[dict]:
"""Pull every cache_control marker from the system block + tail message."""
out = []
sys_blocks = body.get("system") or []
if isinstance(sys_blocks, list):
for b in sys_blocks:
if isinstance(b, dict) and "cache_control" in b:
out.append(b["cache_control"])
msgs = body.get("messages") or []
if msgs:
tail = msgs[-1].get("content")
if isinstance(tail, list):
for b in tail:
if isinstance(b, dict) and "cache_control" in b:
out.append(b["cache_control"])
return out
# ── default (omitted) writes into the 5m pool ──────────────────────
def test_omitted_ttl_uses_default_5m_pool(monkeypatch):
captured = _capture(monkeypatch, ttl = None)
ccs = _cache_controls(captured["body"])
assert len(ccs) == 2, ccs
for cc in ccs:
assert cc == {"type": "ephemeral"}, cc
# ── explicit 5m round-trips as-is ─────────────────────────────────
def test_explicit_5m_ttl_round_trips(monkeypatch):
captured = _capture(monkeypatch, ttl = "5m")
ccs = _cache_controls(captured["body"])
assert len(ccs) == 2, ccs
for cc in ccs:
assert cc == {"type": "ephemeral", "ttl": "5m"}, cc
# ── 1h writes the new pool field on every marker ───────────────────
def test_1h_ttl_writes_into_1h_pool(monkeypatch):
captured = _capture(monkeypatch, ttl = "1h")
ccs = _cache_controls(captured["body"])
assert len(ccs) == 2, ccs
for cc in ccs:
assert cc == {"type": "ephemeral", "ttl": "1h"}, cc
def test_1h_ttl_does_not_send_extended_cache_ttl_beta_header(monkeypatch):
# The `extended-cache-ttl-2025-04-11` beta header that originally
# gated 1h cache TTL has been promoted to GA: verified live against
# api.anthropic.com on 2026-05-22 -- a request with
# `cache_control:{type:"ephemeral", ttl:"1h"}` and NO beta header
# returns 200 and populates `ephemeral_1h_input_tokens`. Pin the
# contract so we don't reintroduce the gate by accident; a future
# regression that re-adds the header would surface here.
captured = _capture(monkeypatch, ttl = "1h")
beta = captured["headers"].get("anthropic-beta", "")
assert "extended-cache-ttl-2025-04-11" not in beta, beta
def test_5m_ttl_does_not_send_extended_cache_ttl_beta_header(monkeypatch):
captured = _capture(monkeypatch, ttl = "5m")
beta = captured["headers"].get("anthropic-beta", "")
assert "extended-cache-ttl-2025-04-11" not in beta, beta
# ── unknown values are dropped, not forwarded ──────────────────────
@pytest.mark.parametrize("bogus", ["6m", "2h", "", "forever", "1d", "0", "1"])
def test_unknown_ttl_silently_dropped(monkeypatch, bogus):
captured = _capture(monkeypatch, ttl = bogus)
ccs = _cache_controls(captured["body"])
assert len(ccs) == 2, ccs
for cc in ccs:
# Bogus TTLs must NOT round-trip; marker stays at the default
# (no `ttl` key, which means the 5m pool upstream).
assert cc == {"type": "ephemeral"}, cc
# ── opt-out still skips cache_control entirely ─────────────────────
def test_opt_out_skips_cache_control(monkeypatch):
captured: dict = {}
def 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(handler)),
)
async def run():
client = _make_client()
async for _ in client.stream_chat_completion(
messages = [
{"role": "system", "content": "Be brief."},
{"role": "user", "content": "hi"},
],
model = "claude-opus-4-7",
temperature = 0.7,
top_p = 0.95,
max_tokens = 32,
enable_prompt_caching = False,
prompt_cache_ttl = "1h", # ignored when caching is off
):
pass
await client.close()
_drive(run())
assert _cache_controls(captured["body"]) == []