unsloth/studio/backend/tests/test_anthropic_admission.py
Daniel Han 2ab4b744ac
Studio: admission control on /v1/messages, slot pool that tracks --parallel (#7436)
* Studio: admission control on /v1/messages, slot pool that tracks --parallel

/v1/chat/completions was gated by the llama admission queue but /v1/messages
was not, so an Anthropic client could oversubscribe llama-server's slots and
stall the backend. Wire the same queue into all six /v1/messages dispatch
sites, and rework the queue itself into an explicit slot pool.

- Queue keyed by base_url, so both API surfaces share one pool of slots.
- Waiting is unbounded by default instead of timing out; the wait line is
  sized at 16 x the serving slots so it follows --parallel.
- Neutral UNSLOTH_LLAMA_ADMISSION_* env names, legacy UNSLOTH_OPENAI_COMPAT_*
  spellings still honored.
- Passthrough retries once against a respawned llama-server, which comes back
  on a new ephemeral port.

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

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

* Fix over-admission on capacity shrink and restore the stream cancel contract

Review of the previous commit turned up two real regressions plus smaller gaps.

- Pool sizing looked only at free slot ids, so when capacity shrank while slots
  were held (an unload resets effective_parallel_slots to 1) a freed low id was
  handed out even though the holdovers already met the new ceiling. Count every
  held slot against capacity instead. A 1-slot backend could run 4 generations.
- The streaming wrapper closed the monitored body with aclose(), delivering
  GeneratorExit where _SameTaskStreamingResponse deliberately throws
  CancelledError. The monitor entry was never finalized, so it leaked as
  "running" for the process lifetime and cancel_event was never set. Close
  through the shared helper so cancellation reaches the handler.
- Finalize the monitor when a stream is abandoned before its body starts, and
  when a queued non-streaming request is cancelled (which also leaked the
  un-awaited generation coroutine).
- Floor the scaled wait line at 64, so a 1-slot backend keeps the depth it had
  before scaling existed instead of dropping from 64 to 16.
- Use the canonical Anthropic type map: a full queue is 429 rate_limit_error,
  which SDKs back off on; overloaded_error is 529.
- Treat non-positive max_queue/queue_per_slot as unbounded rather than "reject
  everything", and reclaim the slot if a waiter's event loop is gone.

Tests: regression tests for both defects, verified to fail without the fix.
Adds env coverage for QUEUE_PER_SLOT and the legacy fallbacks, a structural
check that all six dispatch sites stay admission-wrapped, and clears the new
env var in the isolation fixtures.

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

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

* Run the response pre-start cleanup when a queued stream is abandoned

From automated review of the earlier commits.

_anthropic_passthrough_stream enters its _TrackedCancel eagerly and relies on
the stream's finally to exit it, but aclose() on an async generator that never
started is a no-op, so that finally never runs. Admission made this reachable:
a client that disconnects while queued leaves the cancel id registered in
_CANCEL_REGISTRY forever.

- Give the passthrough response an unstarted_cleanup hook that exits the
  tracker, via a new optional arg on _sse_streaming_response.
- Chain to that hook from the admission wrapper rather than replacing it, and
  run it when the wrapper gives up before the body started.
- Defer to an in-progress MTP fallback instead of respawning underneath it;
  only the first caller gets True from _maybe_recover_from_mtp_crash.

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

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

* Restore Python 3.9 support, and stop the floor overriding an explicit setting

Second review round. The first item is a real break shipped by the earlier
commits, the rest are correctness and contract fixes.

- dataclass(slots = True) and int.bit_count() are both 3.10+, but the package
  declares requires-python >=3.9 and CI only runs 3.12, so nothing caught it.
  Importing the module raised TypeError on 3.9, taking down the whole backend,
  not just admission. Drop the dataclass slots and track the popcount in a
  counter. A test now asserts neither API comes back.
- The queue-depth floor applied even when an operator set QUEUE_PER_SLOT
  explicitly, so asking for a shallow line silently got 64 and, with no queue
  timeout, callers blocked instead of failing fast. The floor now only backs
  the default multiplier.
- Never let a failing close strand a slot: closing runs in its own try so the
  release always happens. A lost slot shrinks the pool permanently.
- Close the generation coroutine when reserving fails for any reason, not only
  on a full queue.
- Exit the passthrough cancel tracker if the client drops while the opening SSE
  lines are still being sent; those yields sit outside the teardown try.
- snapshot.free now reports what a caller could actually take, so the admission
  log cannot show free slots next to queued requests after a shrink.
- Correct the class docstring: the wait line is bounded by default, not
  unlimited. Document that abandoning wait() requires cancel(), and pin the
  thread assumption in _deliver_lease.

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

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

* Make the leak guards real, and cover the untested admission branches

Third review round, which attacked the previous round's tests by reverting each
fix. Two guards turned out to be hollow.

- The pre-start cleanup chain could be severed with the suite still green: the
  existing test drove the generator finally, never the response hook. A real
  pre-start disconnect leaked the passthrough cancel tracker permanently.
  Replaced with a test that runs the response hook and asserts _CANCEL_REGISTRY
  is empty; verified against both ways of reintroducing the leak.
- The structural check only asserted the unstarted_cleanup keyword was present,
  so passing a literal None passed it while leaking. It now asserts the hook is
  actually built.
- test_shares_queue_with_openai_by_base_url never touched the OpenAI helper; it
  was a duplicate under a misleading name. It now reserves through the same
  helper /v1/chat/completions uses, so it fails if either surface ever derives a
  different key. That is the PR's central shared-queue claim.
- Cover the passthrough dispatch site, 499 on disconnect-while-queued, and the
  streaming admission timeout. Four of six sites previously had only an AST
  node count behind them.
- Clear admission env in the autouse fixture rather than per test: an ambient
  canonical name silently beat the legacy name a test was exercising.
- Loosen the wall-clock assertion, which guarded against serialising on the
  uncontended path, not against a slow runner.
- The class docstring claimed a global concurrency cap; Studio's own chat
  endpoint does not reserve, so it is not one.

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

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

* Keep dataclass slots on 3.10+ via a version gate

Dropping slots = True for 3.9 gave it up everywhere, including the 3.12 CI runs
and every supported interpreter but one. Gate it instead: _SLOTS is
{"slots": True} on 3.10+ and empty below, unpacked into each dataclass.

The AST scan now requires the unpack rather than merely forbidding a literal
slots keyword, so a dataclass added later cannot quietly lose slots. Added a
test that the gate matches the running interpreter, since a gate that never
applies is worse than no gate. Verified the 3.9 branch by forcing _SLOTS empty
and reloading: the full admission suite passes either way.

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

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

* Fix two slot leaks, and cover the guards that had no test

Third review round, three reviewers working independently on the admission core,
the route wiring, and whether the PR regresses anything it is not about.

Leaks:

- cancel() made the same call_soon_threadsafe as _grant_waiters_locked but
  without its RuntimeError guard. Routes cancel from finally blocks, so a closed
  loop masked their exception and skipped the release, stranding the slot and
  pinning is_idle() false so the queue was never evicted either.
- The pre-start cleanup released the slot after an await that can raise
  BaseException, which is swallowed upstream. Nested it in a finally, as the
  streaming and OpenAI paths already do.

An unparseable QUEUE_PER_SLOT dropped the 64 floor while falling back to the
default multiplier, quietly giving a 1-slot backend a 16-deep line. Explicit now
means it parsed.

Guards that were correct but had no test. Each was reverted, confirmed the suite
stayed green, then covered and confirmed red:

- the slot released when stream setup raises, which is the reachable one:
  count_chat_tokens is a blocking call to llama-server, so a dead backend raises
  after the slot is taken and before a body exists to release it
- coro.close() on a cancelled queued request, the api_monitor.fail that
  distinguishes an admission timeout from a client hang-up, the MTP fallback
  short-circuit, and the BaseException guard around the opening stream lines
- the queue-full test asserted a type string OpenAI's 429 also uses, so it
  passed against an OpenAI envelope. It now pins the Anthropic shape.

Anthropic requests were invisible in the admission log while sharing the pool
with chat completions, so the same events are logged there with a mode. Renamed
the helper to match, since it is no longer OpenAI-only.

Corrected two comments that described behaviour the code does not have: the
slot is taken when the streaming response is built, not when the body starts
iterating, and the pool is not a cap on every generation, since /v1/completions,
Studio's chat endpoint and RAG captioning all reach llama-server directly.

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

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

* Cover the admission telemetry, and drop a dead helper

Fourth review round. No bugs found in the code this time; the finding was that
most of the previous commit's telemetry had no test. Only queue-full was
asserted, so removing any of the other four log calls left the suite green.

All five are covered now, each verified by removing its call and confirming only
its own test reds. Fixing the first attempt turned up a test bug of my own: the
log line carries a queued=N field, so asserting "queued" in the message matched
every admission log ever emitted. It asserts the event name now.

Also covered two guards that were correct but unguarded: waiters whose futures
die out of band stop counting against the queue depth, and a newcomer cannot
barge past a parked waiter. The second is pinned as behaviour rather than as the
`if not self._waiters` check, because that check cannot actually change the
outcome: _take_slot_locked consults _can_admit_locked anyway, so either alone
refuses the newcomer. The test fails only if both go.

_optional_positive_int_env lost its last caller when the env parsing was
rewritten last round. Removed.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-27 04:35:17 -07:00

973 lines
35 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Admission-control wiring for the Anthropic /v1/messages endpoint.
The FIFO queue itself is unit-tested in test_llama_admission.py; here we exercise
how anthropic_messages reserves a slot, queues when the backend is saturated,
streams keep-alives while waiting, releases on completion, and maps rejects to
429/503. Slot occupancy is driven directly through the shared queue (keyed by the
backend base_url) so generation stays fast and no thread has to block.
"""
from __future__ import annotations
import asyncio
import contextlib
import gc
import os
import re
import sys
import threading
import time
import warnings
from types import SimpleNamespace
import httpx
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
import routes.inference as inf_mod
from routes.inference import (
_anthropic_passthrough_retry_url,
_anthropic_passthrough_stream,
anthropic_messages,
)
from models.inference import AnthropicMessagesRequest
from core.inference.api_monitor import ApiMonitor
from core.inference.llama_admission import (
ADMISSION_CONTROL_ENV,
ADMISSION_KEEPALIVE_INTERVAL_ENV,
ADMISSION_MAX_QUEUE_ENV,
ADMISSION_QUEUE_PER_SLOT_ENV,
ADMISSION_QUEUE_TIMEOUT_ENV,
LlamaAdmissionConfig,
get_llama_admission_queue,
reset_llama_admission_queues,
)
from fastapi import HTTPException
_KEY = "http://llama.admission.test:9999"
@pytest.fixture(autouse = True)
def _isolate(monkeypatch):
reset_llama_admission_queues()
monkeypatch.setattr(inf_mod, "api_monitor", ApiMonitor(max_entries = 64))
monkeypatch.setattr(inf_mod, "_CANCEL_REGISTRY", {})
for name in (
ADMISSION_CONTROL_ENV,
ADMISSION_QUEUE_TIMEOUT_ENV,
ADMISSION_KEEPALIVE_INTERVAL_ENV,
ADMISSION_MAX_QUEUE_ENV,
ADMISSION_QUEUE_PER_SLOT_ENV,
# Legacy spellings resolve too, so clear both for isolation.
"UNSLOTH_OPENAI_COMPAT_ADMISSION_CONTROL",
"UNSLOTH_OPENAI_COMPAT_ADMISSION_QUEUE_TIMEOUT",
"UNSLOTH_OPENAI_COMPAT_ADMISSION_KEEPALIVE_INTERVAL",
"UNSLOTH_OPENAI_COMPAT_ADMISSION_MAX_QUEUE",
):
monkeypatch.delenv(name, raising = False)
yield
reset_llama_admission_queues()
class _Request:
def __init__(self, disconnected = False):
self.state = SimpleNamespace()
self.url = SimpleNamespace(path = "/v1/messages")
self.method = "POST"
self._disconnected = disconnected
async def is_disconnected(self):
return self._disconnected
def _install_backend(
monkeypatch,
*,
slots = 1,
base_url = _KEY,
count_tokens = None,
):
def _gen_plain(**_kwargs):
yield "ok"
def _gen_tools(**_kwargs):
yield {"type": "content", "text": "ok"}
backend = SimpleNamespace(
is_loaded = True,
is_vision = False,
supports_tools = True,
supports_tool_passthrough = False,
model_identifier = "test-model",
context_length = 2048,
count_chat_tokens = count_tokens or (lambda *a, **k: 2),
generate_chat_completion = _gen_plain,
generate_chat_completion_with_tools = _gen_tools,
effective_parallel_slots = slots,
base_url = base_url,
)
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
return backend
def _payload(**fields) -> AnthropicMessagesRequest:
base = {"max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]}
base.update(fields)
return AnthropicMessagesRequest(**base)
def _record_admission_logs(monkeypatch):
"""Capture _llama_admission_log output.
Through the logger rather than caplog: this one is a structlog bound logger,
so it never reaches the stdlib handlers caplog installs.
"""
records = []
def _record(level):
return lambda fmt, *args: records.append((level, fmt % args))
monkeypatch.setattr(
inf_mod,
"logger",
SimpleNamespace(
debug = _record("debug"),
info = _record("info"),
warning = _record("warning"),
),
)
return records
def _snapshot(key = _KEY):
return get_llama_admission_queue(key).snapshot()
def _occupy(key, capacity, n):
"""Hold ``n`` slots on the queue so the next reserve must wait; returns leases."""
leases = []
for _ in range(n):
reservation = get_llama_admission_queue(key).reserve(
capacity = capacity, config = LlamaAdmissionConfig()
)
lease = reservation.lease_nowait()
assert lease is not None
leases.append(lease)
return leases
async def _consume(response):
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk.decode() if isinstance(chunk, (bytes, bytearray)) else chunk)
return "".join(chunks)
# ── Non-streaming ─────────────────────────────────────────────
def test_non_streaming_completes_and_releases_slot(monkeypatch):
_install_backend(monkeypatch, slots = 2)
async def _run():
response = await anthropic_messages(_payload(), request = _Request(), current_subject = "t")
assert response.status_code == 200
snap = _snapshot()
assert snap.active == 0 and snap.queued == 0
asyncio.run(_run())
def test_non_streaming_queue_full_returns_429(monkeypatch):
monkeypatch.setenv(ADMISSION_MAX_QUEUE_ENV, "1")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1) # slot busy
# One waiter fills the max_queue=1; the next reserve rejects.
get_llama_admission_queue(_KEY).reserve(
capacity = 1, config = LlamaAdmissionConfig(max_queue = 1)
)
with pytest.raises(HTTPException) as exc:
await anthropic_messages(_payload(), request = _Request(), current_subject = "t")
assert exc.value.status_code == 429
# rate_limit_error is what Anthropic SDKs back off on; overloaded_error is 529.
# The type string alone does not pin the envelope, since OpenAI's 429 uses the
# same word. Assert the shape too, or emitting an OpenAI body still passes.
detail = exc.value.detail
assert detail["type"] == "error"
assert "request_id" in detail
assert set(detail["error"]) == {"type", "message"}
assert detail["error"]["type"] == "rate_limit_error"
for lease in held:
lease.release()
asyncio.run(_run())
def test_admission_events_are_logged_on_the_anthropic_surface(monkeypatch):
# The OpenAI passthrough logs these with a mode; without the same on /v1/messages
# an operator debugging a slow Anthropic client has nothing to look at, and the
# pool is shared, so it is the same triage.
records = _record_admission_logs(monkeypatch)
monkeypatch.setenv(ADMISSION_MAX_QUEUE_ENV, "1")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
get_llama_admission_queue(_KEY).reserve(
capacity = 1, config = LlamaAdmissionConfig(max_queue = 1)
)
with pytest.raises(HTTPException):
await anthropic_messages(_payload(), request = _Request(), current_subject = "t")
for lease in held:
lease.release()
asyncio.run(_run())
full = [msg for _level, msg in records if "queue-full" in msg]
assert full, records
assert "llama admission queue-full" in full[0]
assert "mode=anthropic_nonstream" in full[0]
def test_streaming_admission_waiting_is_logged(monkeypatch):
# queued and granted-after-wait were both emitted with nothing asserting them.
records = _record_admission_logs(monkeypatch)
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
task = asyncio.create_task(_consume(response))
await asyncio.sleep(0.15)
for lease in held:
lease.release()
await asyncio.wait_for(task, timeout = 5)
asyncio.run(_run())
events = [msg for _level, msg in records if "llama admission" in msg]
# "llama admission queued", not "queued": every line carries a queued=N field,
# so the bare substring matches any admission log at all.
assert any(
"llama admission queued" in m and "mode=anthropic_stream" in m for m in events
), events
granted = [m for m in events if "granted-after-wait" in m]
assert granted, events
# wait_ms is the point of the event: a grant that reports nothing is useless.
assert re.search(r"wait_ms=\d+", granted[0]), granted
def test_streaming_admission_timeout_is_logged(monkeypatch):
records = _record_admission_logs(monkeypatch)
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "0.15")
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1) # never released, so the waiter times out
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
await _consume(response)
for lease in held:
lease.release()
asyncio.run(_run())
timeouts = [msg for level, msg in records if "timeout" in msg and level == "warning"]
assert timeouts, records
assert "mode=anthropic_stream" in timeouts[0]
def test_streaming_give_up_while_queued_is_logged(monkeypatch):
# cancelled-before-upstream is the one that tells an operator a client walked
# away rather than the backend being slow.
records = _record_admission_logs(monkeypatch)
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
response = await anthropic_messages(
_payload(stream = True),
request = _Request(disconnected = True),
current_subject = "t",
)
await _consume(response)
for lease in held:
lease.release()
asyncio.run(_run())
events = [msg for _level, msg in records if "llama admission" in msg]
assert any("llama admission cancelled-before-upstream" in m for m in events), events
def test_non_streaming_times_out_returns_503(monkeypatch):
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "0.15")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1) # never released -> waiter times out
with pytest.raises(HTTPException) as exc:
await anthropic_messages(_payload(), request = _Request(), current_subject = "t")
assert exc.value.status_code == 503
for lease in held:
lease.release()
asyncio.run(_run())
def test_non_streaming_queued_then_admitted(monkeypatch):
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
task = asyncio.create_task(
anthropic_messages(_payload(), request = _Request(), current_subject = "t")
)
await asyncio.sleep(0.1)
assert _snapshot().queued == 1 # waiting on the busy slot
held[0].release() # free it
response = await asyncio.wait_for(task, timeout = 2)
assert response.status_code == 200
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_capacity_enforced_from_effective_parallel_slots(monkeypatch):
_install_backend(monkeypatch, slots = 3)
async def _run():
held = _occupy(_KEY, 3, 3) # all 3 slots busy
task = asyncio.create_task(
anthropic_messages(_payload(), request = _Request(), current_subject = "t")
)
await asyncio.sleep(0.1)
snap = _snapshot()
assert snap.capacity == 3 and snap.active == 3 and snap.queued == 1
for lease in held:
lease.release()
response = await asyncio.wait_for(task, timeout = 2)
assert response.status_code == 200
asyncio.run(_run())
def test_disabled_admission_bypasses_limit(monkeypatch):
monkeypatch.setenv(ADMISSION_CONTROL_ENV, "off")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1) # would block if admission were on
response = await asyncio.wait_for(
anthropic_messages(_payload(), request = _Request(), current_subject = "t"),
timeout = 2,
)
assert response.status_code == 200
for lease in held:
lease.release()
asyncio.run(_run())
# ── Streaming ─────────────────────────────────────────────────
def test_streaming_completes_and_releases_slot(monkeypatch):
_install_backend(monkeypatch, slots = 1)
async def _run():
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
blob = await _consume(response)
assert "event: message_start" in blob
assert "event: message_stop" in blob
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_streaming_emits_keepalives_while_queued_then_streams(monkeypatch):
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = response.body_iterator
# First chunk must be a keep-alive comment (slot still busy).
first = await asyncio.wait_for(body.__anext__(), timeout = 2)
first = first.decode() if isinstance(first, (bytes, bytearray)) else first
assert first.startswith(":") # SSE comment keep-alive
held[0].release() # free the slot -> real stream follows
rest = await asyncio.wait_for(_drain(body), timeout = 2)
assert "event: message_start" in rest
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_streaming_queue_full_returns_429(monkeypatch):
monkeypatch.setenv(ADMISSION_MAX_QUEUE_ENV, "1")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
get_llama_admission_queue(_KEY).reserve(
capacity = 1, config = LlamaAdmissionConfig(max_queue = 1)
)
with pytest.raises(HTTPException) as exc:
await anthropic_messages(_payload(stream = True), request = _Request(), current_subject = "t")
assert exc.value.status_code == 429
for lease in held:
lease.release()
asyncio.run(_run())
def test_streaming_disconnect_while_queued_frees_slot(monkeypatch):
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2) # one keep-alive
assert _snapshot().queued == 1
await body.aclose() # client goes away mid-wait
held[0].release()
await asyncio.sleep(0.05)
snap = _snapshot()
assert snap.queued == 0 and snap.active == 0
asyncio.run(_run())
# ── Shared queue + fairness + speed ───────────────────────────
def test_shares_queue_with_openai_by_base_url(monkeypatch):
"""The two API surfaces must land on one pool of the same llama-server slots.
Reserves through the OpenAI helper the /v1/chat/completions path uses, rather
than poking the queue directly, so this fails if either side ever derives a
different key.
"""
_install_backend(monkeypatch, slots = 1)
async def _run():
openai_reservation, _ = inf_mod._openai_llama_admission_reserve(
request = _Request(), llama_backend = inf_mod.get_llama_cpp_backend()
)
openai_lease = openai_reservation.lease_nowait()
assert openai_lease is not None
assert _snapshot().active == 1 # same key the Anthropic side will use
task = asyncio.create_task(
anthropic_messages(_payload(), request = _Request(), current_subject = "t")
)
await asyncio.sleep(0.1)
assert _snapshot().queued == 1 # queued behind the OpenAI generation
openai_lease.release()
assert (await asyncio.wait_for(task, timeout = 2)).status_code == 200
asyncio.run(_run())
def test_non_streaming_client_gone_while_queued_returns_499(monkeypatch):
# The disconnect-while-queued branch; nothing else exercised 499.
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
with pytest.raises(HTTPException) as exc:
await anthropic_messages(
_payload(), request = _Request(disconnected = True), current_subject = "t"
)
assert exc.value.status_code == 499
assert _snapshot().queued == 0 # waiter cleaned up, not left parked
for lease in held:
lease.release()
asyncio.run(_run())
def test_streaming_timeout_emits_an_error_event_and_frees_the_slot(monkeypatch):
# Only the non-streaming 503 was covered; streaming reports in-band instead.
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "0.15")
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1) # never released, so the waiter times out
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = await _consume(response)
assert "event: error" in body
assert "message_start" not in body # never reached the model
for lease in held:
lease.release()
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_fifo_fairness_across_many_waiters(monkeypatch):
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
order = []
async def _one(i):
resp = await anthropic_messages(_payload(), request = _Request(), current_subject = "t")
order.append(i)
return resp
tasks = [asyncio.create_task(_one(i)) for i in range(8)]
await asyncio.sleep(0.2)
assert _snapshot().queued == 8
held[0].release()
await asyncio.wait_for(asyncio.gather(*tasks), timeout = 5)
assert order == list(range(8)) # granted in arrival order
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_uncontended_hot_path_is_fast(monkeypatch):
_install_backend(monkeypatch, slots = 4)
async def _run():
start = time.perf_counter()
for _ in range(50):
resp = await anthropic_messages(_payload(), request = _Request(), current_subject = "t")
assert resp.status_code == 200
elapsed = time.perf_counter() - start
# Generous ceiling on purpose: this guards against admission accidentally
# serialising or sleeping on the uncontended path, not against a slow
# runner, so it must not flake on a loaded CI box.
assert elapsed < 10.0, f"50 uncontended round-trips took {elapsed:.2f}s"
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
async def _drain(body):
chunks = []
async for chunk in body:
chunks.append(chunk.decode() if isinstance(chunk, (bytes, bytearray)) else chunk)
return "".join(chunks)
def test_streaming_midstream_cancel_finalizes_the_monitor(monkeypatch):
# A mid-stream disconnect is delivered as CancelledError so the monitored body
# can finalize its entry. Closing the inner iterator with aclose() instead
# delivers GeneratorExit, and the entry stays "running" for the process life.
_install_backend(monkeypatch, slots = 1)
async def _run():
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2) # stream started
assert inf_mod.api_monitor.active_count() == 1
# Propagates back out, as the un-admitted path did; what matters is that
# the monitored body saw it on the way through.
with pytest.raises(asyncio.CancelledError):
await body.athrow(asyncio.CancelledError()) # client vanished
assert inf_mod.api_monitor.active_count() == 0
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_streaming_give_up_while_queued_finalizes_the_monitor(monkeypatch):
# Cancelled before the body ever ran, so nothing downstream can close the
# entry out; the wrapper has to do it.
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2) # keep-alive, still queued
assert inf_mod.api_monitor.active_count() == 1
await body.aclose() # give up while waiting
assert inf_mod.api_monitor.active_count() == 0
for lease in held:
lease.release()
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_every_dispatch_site_goes_through_admission():
"""All six generation returns in anthropic_messages are admission-wrapped.
The tool paths need a passthrough-capable backend and a tools payload to reach
at runtime, so guard them structurally instead: a new dispatch site added
without admission (or one reverted to _monitored_anthropic) fails here.
"""
import ast
import inspect
tree = ast.parse(inspect.getsource(inf_mod).replace("\t", " "))
handler = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.AsyncFunctionDef) and node.name == "anthropic_messages"
)
# The wrappers themselves call _monitored_anthropic; only the dispatch sites count.
nested = {
node
for node in ast.walk(handler)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name.startswith("_admitted_anthropic")
}
inner = {id(n) for wrapper in nested for n in ast.walk(wrapper)}
called = []
for node in ast.walk(handler):
if id(node) in inner or not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Name):
called.append(node.func.id)
assert called.count("_admitted_anthropic") == 6
assert called.count("_monitored_anthropic") == 0
def test_queued_give_up_runs_the_response_pre_start_cleanup(monkeypatch):
"""A stream abandoned while queued must run the builder's eager cleanup.
The passthrough enters a _TrackedCancel before returning its response and
relies on the stream's finally to exit it. That finally never runs for a
generator that never started, so the response carries a pre-start hook and
the admission wrapper has to chain to it instead of replacing it.
"""
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
ran = []
async def _hook():
ran.append(True)
real = inf_mod._sse_streaming_response
def _tagged(content, *, unstarted_cleanup = None):
return real(content, unstarted_cleanup = _hook)
monkeypatch.setattr(inf_mod, "_sse_streaming_response", _tagged)
async def _run():
held = _occupy(_KEY, 1, 1)
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2) # keep-alive, still queued
await body.aclose() # give up before the body ran
assert ran == [True]
for lease in held:
lease.release()
asyncio.run(_run())
def test_passthrough_stream_registers_a_pre_start_cleanup():
# Structural guard: the tracker is entered eagerly, so the response must
# carry the hook that exits it when the body never starts.
import ast
import inspect
src = inspect.getsource(inf_mod._anthropic_passthrough_stream)
tree = ast.parse(src.replace("\t", " ").lstrip())
returns = [n for n in ast.walk(tree) if isinstance(n, ast.Return) and n.value is not None]
call = next(
n.value
for n in returns
if isinstance(n.value, ast.Call)
and getattr(n.value.func, "id", "") == "_sse_streaming_response"
)
hook = next(kw.value for kw in call.keywords if kw.arg == "unstarted_cleanup")
# Not just present: a literal None passes the keyword check and still leaks.
assert isinstance(hook, ast.Call)
assert getattr(hook.func, "id", None) == "_tracked_cancel_unstarted_cleanup"
def test_slot_is_released_even_if_closing_the_body_raises(monkeypatch):
# A slot lost here never comes back: with no queue timeout the pool silently
# shrinks and later callers wait forever, so the release must not sit behind
# anything that can throw.
_install_backend(monkeypatch, slots = 1)
async def _boom(iterator, *, cancelled):
raise RuntimeError("close failed")
monkeypatch.setattr(inf_mod, "_close_openai_admitted_stream_iterator", _boom)
async def _run():
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2) # stream started
assert _snapshot().active == 1
with pytest.raises(RuntimeError):
await body.aclose()
assert _snapshot().active == 0 # slot returned despite the failure
# And the pool still serves the next caller.
again = get_llama_admission_queue(_KEY).reserve(capacity = 1, config = LlamaAdmissionConfig())
lease = again.lease_nowait()
assert lease is not None
lease.release()
asyncio.run(_run())
_CLIENT_TOOLS = [
{"name": "get_time", "description": "t", "input_schema": {"type": "object", "properties": {}}}
]
def _passthrough_payload(**fields):
# server_tools off + declared tools + a passthrough-capable backend routes
# anthropic_messages down the client-tool passthrough dispatch site.
return _payload(tools = _CLIENT_TOOLS, enable_tools = False, **fields)
def test_response_pre_start_cleanup_exits_the_passthrough_tracker(monkeypatch):
"""A disconnect before the body starts must still exit the cancel tracker.
The wrapper replaces the response's own pre-start hook, so it has to chain to
it. Asserting through _CANCEL_REGISTRY rather than the wiring, because the
hook can be present and still be a no-op.
"""
backend = _install_backend(monkeypatch, slots = 1)
backend.supports_tool_passthrough = True
monkeypatch.setattr(inf_mod, "_CANCEL_REGISTRY", {})
async def _run():
response = await anthropic_messages(
_passthrough_payload(stream = True), request = _Request(), current_subject = "t"
)
assert inf_mod._CANCEL_REGISTRY, "passthrough should have registered a tracker"
cleanup = getattr(response, "_unstarted_cleanup", None)
assert cleanup is not None
await cleanup() # what _SameTaskStreamingResponse runs on a pre-start disconnect
assert inf_mod._CANCEL_REGISTRY == {}
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_passthrough_dispatch_site_reserves_and_releases(monkeypatch):
# Behavioural cover for a dispatch site the other tests never reach.
backend = _install_backend(monkeypatch, slots = 1)
backend.supports_tool_passthrough = True
async def _run():
held = _occupy(_KEY, 1, 1)
task = asyncio.create_task(
anthropic_messages(_passthrough_payload(), request = _Request(), current_subject = "t")
)
await asyncio.sleep(0.1)
assert _snapshot().queued == 1 # queued behind the busy slot, not bypassing
for lease in held:
lease.release()
with contextlib.suppress(Exception):
await asyncio.wait_for(task, timeout = 2) # upstream is not mocked
assert _snapshot().active == 0 and _snapshot().queued == 0
asyncio.run(_run())
def test_stream_setup_failure_returns_the_slot(monkeypatch):
# count_chat_tokens makes a blocking HTTP call to llama-server, so a dead
# server raises here: after lease_nowait() took the slot, before a body
# exists to release it. Nothing else can hand the slot back.
def _boom(*_a, **_k):
raise RuntimeError("tokenizer unreachable")
_install_backend(monkeypatch, slots = 1, count_tokens = _boom)
async def _run():
with pytest.raises(RuntimeError):
await anthropic_messages(_payload(stream = True), request = _Request(), current_subject = "t")
snap = _snapshot()
assert snap.active == 0, f"slot leaked after stream setup failed: {snap}"
# And the pool still serves the next caller.
again = get_llama_admission_queue(_KEY).reserve(capacity = 1, config = LlamaAdmissionConfig())
assert again.lease_nowait() is not None
asyncio.run(_run())
def test_queued_non_stream_cancel_does_not_leak_a_coroutine(monkeypatch):
# The non-stream path builds the generation coroutine before reserving and
# only awaits it once admitted. Giving up while queued must close it.
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1)
task = asyncio.create_task(
anthropic_messages(_payload(), request = _Request(), current_subject = "t")
)
await asyncio.sleep(0.1)
assert _snapshot().queued == 1
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
for lease in held:
lease.release()
with warnings.catch_warnings(record = True) as caught:
warnings.simplefilter("always")
asyncio.run(_run())
gc.collect()
leaked = [w for w in caught if "never awaited" in str(w.message)]
assert not leaked, [str(w.message) for w in leaked]
def test_stream_timeout_marks_the_monitor_entry_as_error(monkeypatch):
# The finally finishes the entry as "cancelled"; without the fail() first, a
# timed-out request is indistinguishable from a client hang-up in the
# monitor. api_monitor.finish is a no-op on an already terminal entry.
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "0.15")
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.05")
_install_backend(monkeypatch, slots = 1)
async def _run():
held = _occupy(_KEY, 1, 1) # never released, so the waiter times out
response = await anthropic_messages(
_payload(stream = True), request = _Request(), current_subject = "t"
)
async for _ in response.body_iterator:
pass
entries = inf_mod.api_monitor.snapshot()
assert entries and entries[0]["status"] == "error", entries
for lease in held:
lease.release()
asyncio.run(_run())
class _RespawnBackend:
"""Backend whose base_url moves to a new port once respawned."""
def __init__(
self,
*,
mtp_handled = False,
fallback_in_progress = False,
):
self.base_url = "http://127.0.0.1:57953"
self.context_length = 4096
self.respawn_calls = 0
self._mtp_handled = mtp_handled
self._mtp_runtime_fallback_in_progress = fallback_in_progress
def count_chat_tokens(self, *_a, **_k):
return 2
def _maybe_recover_from_mtp_crash(self, _exc):
return self._mtp_handled
def _respawn_if_dead(self):
self.respawn_calls += 1
self.base_url = "http://127.0.0.1:62933"
return True
def test_retry_url_stands_down_while_an_mtp_fallback_is_reloading():
# Only the first caller gets True from _maybe_recover_from_mtp_crash; the rest
# see False and must still stand down, or they respawn the same MTP config
# underneath the fallback already reloading without it.
backend = _RespawnBackend(mtp_handled = False, fallback_in_progress = True)
url = asyncio.run(_anthropic_passthrough_retry_url(backend, httpx.ConnectError("x")))
assert url is None
assert backend.respawn_calls == 0
class _PtRequest:
async def is_disconnected(self):
return False
async def _passthrough_response(backend):
return await _anthropic_passthrough_stream(
_PtRequest(),
threading.Event(),
backend,
[{"role": "user", "content": "hi"}],
[],
0.7,
0.95,
20,
16,
"msg_tracker_probe",
"test-model",
)
def test_disconnect_during_the_opening_lines_exits_the_tracker():
# Suspended inside emitter.start()'s yields the generator has not reached the
# try/finally that exits the tracker, so those yields need their own.
backend = _RespawnBackend()
async def _run():
response = await _passthrough_response(backend)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2) # first start line
assert inf_mod._CANCEL_REGISTRY, "tracker should be registered"
await body.aclose()
assert inf_mod._CANCEL_REGISTRY == {}, "tracker leaked"
asyncio.run(_run())
def test_cancel_during_the_opening_lines_exits_the_tracker():
# Same window, delivered the way _SameTaskStreamingResponse delivers it.
backend = _RespawnBackend()
async def _run():
response = await _passthrough_response(backend)
body = response.body_iterator
await asyncio.wait_for(body.__anext__(), timeout = 2)
assert inf_mod._CANCEL_REGISTRY, "tracker should be registered"
with pytest.raises(asyncio.CancelledError):
await body.athrow(asyncio.CancelledError())
assert inf_mod._CANCEL_REGISTRY == {}, "tracker leaked"
asyncio.run(_run())