* 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>
201 lines
6.9 KiB
Python
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"]) == []
|