@miller-tech/uap 1.172.12 → 1.172.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +159 -57
- package/tools/agents/tests/test_client_disconnect.py +18 -19
- package/tools/agents/tests/test_disconnect_watcher.py +174 -0
- package/tools/agents/tests/test_upstream_chokepoint.py +178 -0
- package/tools/agents/tests/test_inflight_cancel.py +0 -129
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -2766,7 +2766,7 @@ def _detach_aclose(closeable) -> None:
|
|
|
2766
2766
|
|
|
2767
2767
|
def _build_http_client() -> httpx.AsyncClient:
|
|
2768
2768
|
"""Upstream client factory — used at startup AND by pool self-healing."""
|
|
2769
|
-
c =
|
|
2769
|
+
c = DisconnectAwareClient(
|
|
2770
2770
|
timeout=httpx.Timeout(
|
|
2771
2771
|
connect=10.0, # 10s to establish connection
|
|
2772
2772
|
read=PROXY_READ_TIMEOUT, # configurable (default 10 min)
|
|
@@ -2970,9 +2970,30 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
|
|
|
2970
2970
|
#
|
|
2971
2971
|
# Same ContextVar trick as the session above: request-local without threading a
|
|
2972
2972
|
# Request through every signature. Holds Starlette's `request.is_disconnected`.
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2973
|
+
# Is the DOWNSTREAM client still there?
|
|
2974
|
+
#
|
|
2975
|
+
# Nothing used to ask. Once a turn was accepted, the guardrail loops kept
|
|
2976
|
+
# retrying and re-POSTing upstream on their own — so when the client died the
|
|
2977
|
+
# work carried on regardless. Observed live: two `uap deliver` runs were killed,
|
|
2978
|
+
# no socket remained on :4000, and the proxy still drove llama for minutes,
|
|
2979
|
+
# generating 32k tokens per orphaned turn that nobody would ever read.
|
|
2980
|
+
#
|
|
2981
|
+
# WHY NOT request.is_disconnected(). That was tried and MEASURED not to work
|
|
2982
|
+
# here: it returned False 16 times across 30s while the caller was demonstrably
|
|
2983
|
+
# gone. The cause is `@app.middleware("http")` — Starlette's BaseHTTPMiddleware
|
|
2984
|
+
# runs the endpoint in a separate anyio task behind its own receive channel, so
|
|
2985
|
+
# the endpoint polls a channel that never delivers http.disconnect. It works
|
|
2986
|
+
# fine in an app with no middleware, which is exactly why three rounds of
|
|
2987
|
+
# isolated testing said it was sound.
|
|
2988
|
+
#
|
|
2989
|
+
# So the signal is captured by a pure-ASGI middleware OUTSIDE all of that (see
|
|
2990
|
+
# DisconnectWatcherMiddleware) and published here as a mutable holder. A dict
|
|
2991
|
+
# rather than a bool because the middleware's poller task and the endpoint must
|
|
2992
|
+
# see the same object: the ContextVar is set before the app is invoked, so every
|
|
2993
|
+
# downstream task inherits the reference, and mutations are visible across them.
|
|
2994
|
+
_disconnect_holder: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
|
|
2995
|
+
"uap_disconnect_holder", default=None
|
|
2996
|
+
)
|
|
2976
2997
|
|
|
2977
2998
|
|
|
2978
2999
|
class ClientGoneError(Exception):
|
|
@@ -2986,19 +3007,17 @@ class ClientGoneError(Exception):
|
|
|
2986
3007
|
|
|
2987
3008
|
|
|
2988
3009
|
async def _client_gone() -> bool:
|
|
2989
|
-
"""True when the caller has hung up.
|
|
3010
|
+
"""True when the caller has hung up.
|
|
2990
3011
|
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3012
|
+
Now a flag read rather than a call into Starlette, so there is nothing to
|
|
3013
|
+
raise and nothing for a fail-safe except to swallow. The previous version
|
|
3014
|
+
wrapped request.is_disconnected() in `except Exception: return False`, which
|
|
3015
|
+
would have hidden a failing probe as "client still present" — it turned out
|
|
3016
|
+
the probe was not raising, but the shape was wrong regardless.
|
|
2994
3017
|
"""
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
try:
|
|
2999
|
-
return bool(await probe())
|
|
3000
|
-
except Exception: # noqa: BLE001
|
|
3001
|
-
return False
|
|
3018
|
+
holder = _disconnect_holder.get()
|
|
3019
|
+
return bool(holder and holder.get("gone"))
|
|
3020
|
+
|
|
3002
3021
|
|
|
3003
3022
|
# Session admission state. _admitted_sessions maps session_id -> last-seen
|
|
3004
3023
|
# monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
|
|
@@ -3364,47 +3383,50 @@ PROXY_DISCONNECT_POLL_SECS = max(
|
|
|
3364
3383
|
)
|
|
3365
3384
|
|
|
3366
3385
|
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
) -> httpx.Response:
|
|
3370
|
-
"""POST upstream, cancelling it if the caller hangs up mid-generation.
|
|
3386
|
+
class DisconnectAwareClient(httpx.AsyncClient):
|
|
3387
|
+
"""An httpx client that abandons a call when the caller hangs up.
|
|
3371
3388
|
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3389
|
+
THE CHOKE POINT. The previous attempt guarded ONE call site and claimed to
|
|
3390
|
+
cover them all; there are fourteen, and the guardrail loops that caused the
|
|
3391
|
+
incident call the model directly rather than through _post_with_retry. So
|
|
3392
|
+
the check belongs where it cannot be missed: every high-level httpx method —
|
|
3393
|
+
post(), stream(), request() — funnels through send(), so overriding send()
|
|
3394
|
+
covers all of them, including any added later.
|
|
3377
3395
|
|
|
3378
|
-
Cancelling
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3396
|
+
Cancelling matters rather than merely returning: closing the connection is
|
|
3397
|
+
what makes llama.cpp release the slot (verified against the running server —
|
|
3398
|
+
slots went 2 -> 0 within 15s of a raw socket close). Returning early without
|
|
3399
|
+
cancelling would leave the model generating exactly as before.
|
|
3382
3400
|
|
|
3383
|
-
No probe (background task, tests) means
|
|
3401
|
+
No probe (background task, health check, tests) means a plain send,
|
|
3402
|
+
unwatched. Streaming sends are covered for their header phase; a body being
|
|
3403
|
+
written to a vanished client fails on write anyway.
|
|
3384
3404
|
"""
|
|
3385
|
-
probe = _current_client_gone.get()
|
|
3386
|
-
if probe is None:
|
|
3387
|
-
return await client.post(url, json=payload, headers=headers)
|
|
3388
3405
|
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3406
|
+
async def send(self, request, **kwargs): # type: ignore[override]
|
|
3407
|
+
holder = _disconnect_holder.get()
|
|
3408
|
+
if holder is None:
|
|
3409
|
+
return await super().send(request, **kwargs)
|
|
3410
|
+
|
|
3411
|
+
task = asyncio.ensure_future(super().send(request, **kwargs))
|
|
3412
|
+
try:
|
|
3413
|
+
while True:
|
|
3414
|
+
done, _pending = await asyncio.wait(
|
|
3415
|
+
{task}, timeout=PROXY_DISCONNECT_POLL_SECS
|
|
3416
|
+
)
|
|
3417
|
+
if task in done:
|
|
3418
|
+
return task.result()
|
|
3419
|
+
if await _client_gone():
|
|
3420
|
+
task.cancel()
|
|
3421
|
+
try:
|
|
3422
|
+
await task
|
|
3423
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
3424
|
+
pass # cancellation is the point; the connection is closed
|
|
3425
|
+
raise ClientGoneError("client disconnected mid-generation")
|
|
3426
|
+
finally:
|
|
3427
|
+
# Never leave an upstream call running once we stop waiting on it.
|
|
3428
|
+
if not task.done():
|
|
3429
|
+
task.cancel()
|
|
3408
3430
|
|
|
3409
3431
|
|
|
3410
3432
|
async def _post_with_retry_inner(
|
|
@@ -3418,7 +3440,7 @@ async def _post_with_retry_inner(
|
|
|
3418
3440
|
try:
|
|
3419
3441
|
_inflight_inc(client)
|
|
3420
3442
|
try:
|
|
3421
|
-
resp = await
|
|
3443
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
3422
3444
|
finally:
|
|
3423
3445
|
_inflight_dec(client)
|
|
3424
3446
|
# Cycle 19 Option 1: if 503 "Loading model", wait for health then retry
|
|
@@ -3495,7 +3517,7 @@ async def _check_slot_hang(slot_url: str) -> bool:
|
|
|
3495
3517
|
if PROXY_SLOT_HANG_TIMEOUT <= 0:
|
|
3496
3518
|
return False
|
|
3497
3519
|
try:
|
|
3498
|
-
async with
|
|
3520
|
+
async with DisconnectAwareClient() as check_client:
|
|
3499
3521
|
resp = await check_client.get(slot_url, timeout=5.0)
|
|
3500
3522
|
if resp.status_code != 200:
|
|
3501
3523
|
return False
|
|
@@ -3632,6 +3654,73 @@ async def lifespan(app: FastAPI):
|
|
|
3632
3654
|
logger.info("Proxy shut down")
|
|
3633
3655
|
|
|
3634
3656
|
|
|
3657
|
+
class DisconnectWatcherMiddleware:
|
|
3658
|
+
"""Pure-ASGI middleware that records when the caller hangs up.
|
|
3659
|
+
|
|
3660
|
+
Sits OUTERMOST, above the auth middleware, and is deliberately pure ASGI:
|
|
3661
|
+
Starlette's BaseHTTPMiddleware (`@app.middleware("http")`) re-tasks the
|
|
3662
|
+
endpoint behind its own receive channel, which is precisely why
|
|
3663
|
+
request.is_disconnected() reported False 16 times across 30s while the
|
|
3664
|
+
caller was gone. Being outside that, this sees the real channel.
|
|
3665
|
+
|
|
3666
|
+
THE HARD PART is that wrapping receive() is not enough on its own. After the
|
|
3667
|
+
request body is fully read nothing calls receive() again, so an
|
|
3668
|
+
http.disconnect just sits in the channel unobserved. So once the body is
|
|
3669
|
+
complete this starts polling the channel itself — safe precisely because the
|
|
3670
|
+
app has no more body to ask for, and the only remaining message type is the
|
|
3671
|
+
disconnect.
|
|
3672
|
+
|
|
3673
|
+
The flag lives in a mutable dict published on a ContextVar before the app is
|
|
3674
|
+
invoked: downstream tasks inherit the reference when their context is copied,
|
|
3675
|
+
so a mutation made by the poller is visible to the endpoint and to every
|
|
3676
|
+
upstream call it makes.
|
|
3677
|
+
"""
|
|
3678
|
+
|
|
3679
|
+
def __init__(self, app):
|
|
3680
|
+
self.app = app
|
|
3681
|
+
|
|
3682
|
+
async def __call__(self, scope, receive, send):
|
|
3683
|
+
if scope.get("type") != "http":
|
|
3684
|
+
return await self.app(scope, receive, send)
|
|
3685
|
+
|
|
3686
|
+
holder: dict = {"gone": False}
|
|
3687
|
+
_disconnect_holder.set(holder)
|
|
3688
|
+
poller: asyncio.Task | None = None
|
|
3689
|
+
|
|
3690
|
+
async def poll_for_disconnect() -> None:
|
|
3691
|
+
# Only reached once the body is complete, so this cannot steal a
|
|
3692
|
+
# message the app still needs.
|
|
3693
|
+
try:
|
|
3694
|
+
while not holder["gone"]:
|
|
3695
|
+
msg = await receive()
|
|
3696
|
+
if msg.get("type") == "http.disconnect":
|
|
3697
|
+
holder["gone"] = True
|
|
3698
|
+
return
|
|
3699
|
+
except asyncio.CancelledError:
|
|
3700
|
+
raise
|
|
3701
|
+
except Exception: # noqa: BLE001
|
|
3702
|
+
return # channel closed/erroring: stop watching, never crash
|
|
3703
|
+
|
|
3704
|
+
async def watched_receive():
|
|
3705
|
+
nonlocal poller
|
|
3706
|
+
msg = await receive()
|
|
3707
|
+
if msg.get("type") == "http.disconnect":
|
|
3708
|
+
holder["gone"] = True
|
|
3709
|
+
elif msg.get("type") == "http.request" and not msg.get("more_body", False):
|
|
3710
|
+
# Body complete — nobody will call receive() again, so take over.
|
|
3711
|
+
if poller is None:
|
|
3712
|
+
poller = asyncio.ensure_future(poll_for_disconnect())
|
|
3713
|
+
return msg
|
|
3714
|
+
|
|
3715
|
+
try:
|
|
3716
|
+
await self.app(scope, watched_receive, send)
|
|
3717
|
+
finally:
|
|
3718
|
+
if poller is not None and not poller.done():
|
|
3719
|
+
poller.cancel()
|
|
3720
|
+
|
|
3721
|
+
|
|
3722
|
+
|
|
3723
|
+
|
|
3635
3724
|
app = FastAPI(
|
|
3636
3725
|
title="UAP Anthropic Proxy",
|
|
3637
3726
|
description="Translates Anthropic Messages API to OpenAI Chat Completions API",
|
|
@@ -3639,6 +3728,7 @@ app = FastAPI(
|
|
|
3639
3728
|
lifespan=lifespan,
|
|
3640
3729
|
)
|
|
3641
3730
|
|
|
3731
|
+
|
|
3642
3732
|
@app.exception_handler(ClientGoneError)
|
|
3643
3733
|
async def _client_gone_handler(request: Request, exc: ClientGoneError):
|
|
3644
3734
|
"""The caller hung up mid-turn: stop, and say so at INFO rather than ERROR.
|
|
@@ -3693,6 +3783,18 @@ async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
|
|
|
3693
3783
|
_PROXY_AUTH_OPEN_PATHS = frozenset({"/health", "/", "/v1/models"})
|
|
3694
3784
|
|
|
3695
3785
|
|
|
3786
|
+
# OUTERMOST — and it must be registered AFTER the auth middleware below to BE
|
|
3787
|
+
# outermost: Starlette's add_middleware inserts at position 0, so the last one
|
|
3788
|
+
# registered is the outermost wrapper. (Registered before auth first time round,
|
|
3789
|
+
# which would have put it INSIDE the very BaseHTTPMiddleware that hides
|
|
3790
|
+
# http.disconnect — the exact bug it exists to work around.)
|
|
3791
|
+
#
|
|
3792
|
+
# The registration therefore trails the auth definition on purpose. Auth itself
|
|
3793
|
+
# is deliberately untouched.
|
|
3794
|
+
def _install_disconnect_watcher() -> None:
|
|
3795
|
+
app.add_middleware(DisconnectWatcherMiddleware)
|
|
3796
|
+
|
|
3797
|
+
|
|
3696
3798
|
@app.middleware("http")
|
|
3697
3799
|
async def _shared_secret_auth(request: Request, call_next):
|
|
3698
3800
|
"""Gate every request behind PROXY_AUTH_TOKEN when it is set.
|
|
@@ -3751,6 +3853,10 @@ async def _shared_secret_auth(request: Request, call_next):
|
|
|
3751
3853
|
# ===========================================================================
|
|
3752
3854
|
|
|
3753
3855
|
|
|
3856
|
+
|
|
3857
|
+
# Auth is now defined, so this lands OUTSIDE it (add_middleware inserts at 0).
|
|
3858
|
+
_install_disconnect_watcher()
|
|
3859
|
+
|
|
3754
3860
|
def _image_block_to_openai(block: dict) -> dict | None:
|
|
3755
3861
|
"""Anthropic image block → OpenAI image_url part (data URI or URL)."""
|
|
3756
3862
|
src = block.get("source") or {}
|
|
@@ -10905,10 +11011,6 @@ async def messages(request: Request):
|
|
|
10905
11011
|
"""
|
|
10906
11012
|
global last_session_id
|
|
10907
11013
|
|
|
10908
|
-
# Publish this request's disconnect probe for the whole call tree. Set before
|
|
10909
|
-
# ANY upstream work so an already-dead client is caught on the first call.
|
|
10910
|
-
_current_client_gone.set(request.is_disconnected)
|
|
10911
|
-
|
|
10912
11014
|
body = await request.json()
|
|
10913
11015
|
is_stream = body.get("stream", False)
|
|
10914
11016
|
model = body.get("model", "default")
|
|
@@ -28,33 +28,32 @@ proxy = _load_proxy()
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
class ClientGoneProbeTest(unittest.TestCase):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
"""The probe is now a FLAG READ, not a call into Starlette.
|
|
32
|
+
|
|
33
|
+
It used to call request.is_disconnected() wrapped in `except Exception:
|
|
34
|
+
return False`. That was measured not to work at all behind
|
|
35
|
+
BaseHTTPMiddleware (False 16x across 30s while the caller was gone), and the
|
|
36
|
+
swallow-everything shape would have hidden a failing probe as "client still
|
|
37
|
+
present". Both are gone: there is nothing left to raise.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def test_no_holder_means_client_is_present(self):
|
|
41
|
+
"""Background tasks and health checks have no request context. They must
|
|
42
|
+
not read as disconnected."""
|
|
43
|
+
proxy._disconnect_holder.set(None)
|
|
35
44
|
self.assertFalse(asyncio.run(proxy._client_gone()))
|
|
36
45
|
|
|
37
46
|
def test_reports_disconnect(self):
|
|
38
|
-
|
|
39
|
-
return True
|
|
40
|
-
|
|
41
|
-
proxy._current_client_gone.set(gone)
|
|
47
|
+
proxy._disconnect_holder.set({"gone": True})
|
|
42
48
|
self.assertTrue(asyncio.run(proxy._client_gone()))
|
|
43
49
|
|
|
44
50
|
def test_reports_connected(self):
|
|
45
|
-
|
|
46
|
-
return False
|
|
47
|
-
|
|
48
|
-
proxy._current_client_gone.set(here)
|
|
51
|
+
proxy._disconnect_holder.set({"gone": False})
|
|
49
52
|
self.assertFalse(asyncio.run(proxy._client_gone()))
|
|
50
53
|
|
|
51
|
-
def
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
async def broken() -> bool:
|
|
55
|
-
raise RuntimeError("receive channel exploded")
|
|
56
|
-
|
|
57
|
-
proxy._current_client_gone.set(broken)
|
|
54
|
+
def test_a_malformed_holder_reads_as_present(self):
|
|
55
|
+
"""Defensive: an empty dict must not be mistaken for a disconnect."""
|
|
56
|
+
proxy._disconnect_holder.set({})
|
|
58
57
|
self.assertFalse(asyncio.run(proxy._client_gone()))
|
|
59
58
|
|
|
60
59
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure-ASGI disconnect watcher — the thing that finally made the abort work.
|
|
3
|
+
|
|
4
|
+
Four attempts failed before this. The measured cause: `@app.middleware("http")`
|
|
5
|
+
is Starlette's BaseHTTPMiddleware, which runs the endpoint in a separate anyio
|
|
6
|
+
task behind its own receive channel, so request.is_disconnected() in the endpoint
|
|
7
|
+
polls a channel that never delivers http.disconnect. Instrumented in production
|
|
8
|
+
it returned False 16 times across 30s while the caller was demonstrably gone —
|
|
9
|
+
and it worked perfectly in an app with no middleware, which is exactly why three
|
|
10
|
+
rounds of isolated testing said the approach was sound.
|
|
11
|
+
|
|
12
|
+
Two properties are load-bearing and each has a test that fails if it breaks:
|
|
13
|
+
|
|
14
|
+
1. ORDER. The watcher must be OUTERMOST. Starlette's add_middleware inserts at
|
|
15
|
+
position 0, so it must be registered LAST. The first implementation
|
|
16
|
+
registered it before auth, putting it inside the very middleware whose
|
|
17
|
+
behaviour it exists to work around.
|
|
18
|
+
2. POLLING. Wrapping receive() is not enough. Once the body is fully read
|
|
19
|
+
nothing calls receive() again, so an http.disconnect sits in the channel
|
|
20
|
+
unobserved. The watcher must take over polling after the final body message.
|
|
21
|
+
|
|
22
|
+
Verified end to end after these were in place: the llama slot freed within 5s of
|
|
23
|
+
the client hanging up, against 30s+ of continued generation before.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import asyncio
|
|
28
|
+
import importlib.util
|
|
29
|
+
import unittest
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
PROXY_PATH = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_proxy():
|
|
36
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_watcher", PROXY_PATH)
|
|
37
|
+
assert spec is not None and spec.loader is not None
|
|
38
|
+
m = importlib.util.module_from_spec(spec)
|
|
39
|
+
spec.loader.exec_module(m)
|
|
40
|
+
return m
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
proxy = _load_proxy()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MiddlewareOrderTest(unittest.TestCase):
|
|
47
|
+
def test_the_watcher_is_outermost(self):
|
|
48
|
+
"""If it ends up inside BaseHTTPMiddleware it cannot see http.disconnect,
|
|
49
|
+
which is the whole failure this replaced."""
|
|
50
|
+
names = [getattr(mw.cls, "__name__", str(mw.cls)) for mw in proxy.app.user_middleware]
|
|
51
|
+
self.assertTrue(names, "no middleware registered at all")
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
names[0],
|
|
54
|
+
"DisconnectWatcherMiddleware",
|
|
55
|
+
f"watcher is not outermost; stack is {names}",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def test_auth_middleware_is_still_installed(self):
|
|
59
|
+
"""The brief was to leave auth alone. Losing it would silently open the
|
|
60
|
+
LAN token gate — a far worse outcome than the bug being fixed."""
|
|
61
|
+
names = [getattr(mw.cls, "__name__", str(mw.cls)) for mw in proxy.app.user_middleware]
|
|
62
|
+
self.assertIn("BaseHTTPMiddleware", names)
|
|
63
|
+
|
|
64
|
+
def test_probe_is_a_flag_read_not_a_starlette_call(self):
|
|
65
|
+
"""_client_gone must not call back into the request object: that is the
|
|
66
|
+
path that measured False-forever behind BaseHTTPMiddleware.
|
|
67
|
+
|
|
68
|
+
Checked with ast rather than substring matching — the first version of
|
|
69
|
+
this test failed on the word appearing in the function's own docstring,
|
|
70
|
+
which is a false positive, not a finding.
|
|
71
|
+
"""
|
|
72
|
+
tree = ast.parse(PROXY_PATH.read_text())
|
|
73
|
+
fn = next(
|
|
74
|
+
n for n in ast.walk(tree)
|
|
75
|
+
if isinstance(n, ast.AsyncFunctionDef) and n.name == "_client_gone"
|
|
76
|
+
)
|
|
77
|
+
attrs = {
|
|
78
|
+
n.attr for n in ast.walk(fn) if isinstance(n, ast.Attribute)
|
|
79
|
+
}
|
|
80
|
+
self.assertNotIn("is_disconnected", attrs)
|
|
81
|
+
# And it must read the holder, so the test fails if the body is gutted.
|
|
82
|
+
self.assertIn("get", attrs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WatcherBehaviourTest(unittest.TestCase):
|
|
86
|
+
"""Drive the middleware directly with an ASGI receive/send pair."""
|
|
87
|
+
|
|
88
|
+
def _run(self, messages, app_body):
|
|
89
|
+
"""messages: what receive() yields, in order. app_body: coroutine fn(scope, receive, send)."""
|
|
90
|
+
sent = []
|
|
91
|
+
queue = list(messages)
|
|
92
|
+
|
|
93
|
+
async def receive():
|
|
94
|
+
if queue:
|
|
95
|
+
return queue.pop(0)
|
|
96
|
+
await asyncio.sleep(3600) # channel idle, like a live connection
|
|
97
|
+
|
|
98
|
+
async def send(msg):
|
|
99
|
+
sent.append(msg)
|
|
100
|
+
|
|
101
|
+
mw = proxy.DisconnectWatcherMiddleware(app_body)
|
|
102
|
+
asyncio.run(mw({"type": "http"}, receive, send))
|
|
103
|
+
return sent
|
|
104
|
+
|
|
105
|
+
def test_records_a_disconnect_the_app_itself_reads(self):
|
|
106
|
+
seen = {}
|
|
107
|
+
|
|
108
|
+
async def app(scope, receive, send):
|
|
109
|
+
await receive() # body
|
|
110
|
+
msg = await receive() # disconnect
|
|
111
|
+
seen["type"] = msg["type"]
|
|
112
|
+
seen["gone"] = await proxy._client_gone()
|
|
113
|
+
|
|
114
|
+
self._run(
|
|
115
|
+
[{"type": "http.request", "body": b"{}", "more_body": False},
|
|
116
|
+
{"type": "http.disconnect"}],
|
|
117
|
+
app,
|
|
118
|
+
)
|
|
119
|
+
self.assertEqual(seen["type"], "http.disconnect")
|
|
120
|
+
self.assertTrue(seen["gone"], "watcher did not record the disconnect")
|
|
121
|
+
|
|
122
|
+
def test_records_a_disconnect_the_app_NEVER_reads(self):
|
|
123
|
+
"""The real case. After the body is consumed the app never calls receive()
|
|
124
|
+
again, so the disconnect is only ever seen by the watcher's own poller.
|
|
125
|
+
Wrapping receive() alone would leave `gone` False here forever."""
|
|
126
|
+
seen = {}
|
|
127
|
+
|
|
128
|
+
async def app(scope, receive, send):
|
|
129
|
+
await receive() # body only — then just "work", like a generation
|
|
130
|
+
for _ in range(40):
|
|
131
|
+
await asyncio.sleep(0.02)
|
|
132
|
+
if await proxy._client_gone():
|
|
133
|
+
seen["gone"] = True
|
|
134
|
+
return
|
|
135
|
+
seen["gone"] = False
|
|
136
|
+
|
|
137
|
+
self._run(
|
|
138
|
+
[{"type": "http.request", "body": b"{}", "more_body": False},
|
|
139
|
+
{"type": "http.disconnect"}],
|
|
140
|
+
app,
|
|
141
|
+
)
|
|
142
|
+
self.assertTrue(seen.get("gone"), "poller did not observe the unread disconnect")
|
|
143
|
+
|
|
144
|
+
def test_a_live_request_is_never_marked_gone(self):
|
|
145
|
+
"""A false positive would abort healthy turns — worse than the bug."""
|
|
146
|
+
seen = {}
|
|
147
|
+
|
|
148
|
+
async def app(scope, receive, send):
|
|
149
|
+
await receive()
|
|
150
|
+
for _ in range(10):
|
|
151
|
+
await asyncio.sleep(0.02)
|
|
152
|
+
seen["gone"] = await proxy._client_gone()
|
|
153
|
+
|
|
154
|
+
self._run([{"type": "http.request", "body": b"{}", "more_body": False}], app)
|
|
155
|
+
self.assertFalse(seen["gone"])
|
|
156
|
+
|
|
157
|
+
def test_non_http_scopes_pass_straight_through(self):
|
|
158
|
+
"""Lifespan and websocket scopes have no disconnect semantics here and
|
|
159
|
+
must not be wrapped."""
|
|
160
|
+
called = {}
|
|
161
|
+
|
|
162
|
+
async def app(scope, receive, send):
|
|
163
|
+
called["type"] = scope["type"]
|
|
164
|
+
|
|
165
|
+
async def go():
|
|
166
|
+
mw = proxy.DisconnectWatcherMiddleware(app)
|
|
167
|
+
await mw({"type": "lifespan"}, None, None)
|
|
168
|
+
|
|
169
|
+
asyncio.run(go())
|
|
170
|
+
self.assertEqual(called["type"], "lifespan")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
unittest.main()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Every upstream call must pass the disconnect check — structurally, not by claim.
|
|
3
|
+
|
|
4
|
+
This test exists because of a specific mistake. Two releases (v1.172.11,
|
|
5
|
+
v1.172.12) guarded ONE call site while the commit message asserted that "every
|
|
6
|
+
upstream call funnels through _post_with_retry, so one check covers them all".
|
|
7
|
+
There are fourteen upstream call sites, and the guardrail loops that caused the
|
|
8
|
+
incident — malformed-tool retry, completion-contract, empty-max-tokens recovery,
|
|
9
|
+
unexpected-end-turn, recipe — call the model DIRECTLY. Both releases were
|
|
10
|
+
therefore near-no-ops for the workload they were written for, and every unit
|
|
11
|
+
test still passed.
|
|
12
|
+
|
|
13
|
+
The fix was to move the check somewhere it cannot be missed: httpx routes every
|
|
14
|
+
high-level method (post, stream, request) through send(), so DisconnectAwareClient
|
|
15
|
+
overrides send() and the coverage becomes a property of the client rather than a
|
|
16
|
+
property of remembering.
|
|
17
|
+
|
|
18
|
+
These tests pin that property. A future call site added anywhere in the proxy is
|
|
19
|
+
covered automatically; a future *client* constructed bare would not be, and that
|
|
20
|
+
is exactly what the enumeration below fails on.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import asyncio
|
|
25
|
+
import importlib.util
|
|
26
|
+
import re
|
|
27
|
+
import unittest
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
PROXY_PATH = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_proxy():
|
|
34
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_choke", PROXY_PATH)
|
|
35
|
+
assert spec is not None and spec.loader is not None
|
|
36
|
+
m = importlib.util.module_from_spec(spec)
|
|
37
|
+
spec.loader.exec_module(m)
|
|
38
|
+
return m
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
proxy = _load_proxy()
|
|
42
|
+
SRC = PROXY_PATH.read_text()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChokePointEnumerationTest(unittest.TestCase):
|
|
46
|
+
def test_no_bare_httpx_client_is_ever_constructed(self):
|
|
47
|
+
"""The invariant. A bare httpx.AsyncClient bypasses the override, so its
|
|
48
|
+
calls silently go unwatched — which is precisely how the first two
|
|
49
|
+
attempts failed, one call site at a time."""
|
|
50
|
+
bare = [
|
|
51
|
+
(i + 1, l.strip())
|
|
52
|
+
for i, l in enumerate(SRC.split("\n"))
|
|
53
|
+
if re.search(r"httpx\.AsyncClient\s*\(", l) and "class " not in l
|
|
54
|
+
]
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
bare, [], f"these construct an unwatched client: {bare}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def test_the_client_actually_subclasses_httpx(self):
|
|
60
|
+
"""Guards the reverse mistake: a wrapper that no longer IS an httpx
|
|
61
|
+
client would break every caller rather than fail this suite loudly."""
|
|
62
|
+
import httpx
|
|
63
|
+
|
|
64
|
+
self.assertTrue(issubclass(proxy.DisconnectAwareClient, httpx.AsyncClient))
|
|
65
|
+
|
|
66
|
+
def test_send_is_overridden_on_the_client_itself(self):
|
|
67
|
+
"""send() is the funnel. If a refactor moves the logic off send() onto,
|
|
68
|
+
say, post(), then stream() and request() silently lose their guard."""
|
|
69
|
+
self.assertIn("send", vars(proxy.DisconnectAwareClient))
|
|
70
|
+
|
|
71
|
+
def test_every_upstream_call_site_is_reachable_through_send(self):
|
|
72
|
+
"""Inventory, so the count cannot quietly drift.
|
|
73
|
+
|
|
74
|
+
Any client.post / client.stream / client.send in the proxy is covered by
|
|
75
|
+
the override — that is the point of a choke point. This asserts the call
|
|
76
|
+
sites still exist in numbers consistent with the audit (they were the
|
|
77
|
+
thing miscounted), and that none of them re-implements its own transport.
|
|
78
|
+
"""
|
|
79
|
+
calls = re.findall(r"client\.(post|send|stream)\s*\(", SRC)
|
|
80
|
+
self.assertGreaterEqual(
|
|
81
|
+
len(calls), 10, "upstream call sites vanished — was transport re-implemented?"
|
|
82
|
+
)
|
|
83
|
+
# No call site may build its own client inline and slip the override.
|
|
84
|
+
self.assertNotRegex(SRC, r"await\s+httpx\.AsyncClient\s*\(")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ChokePointBehaviourTest(unittest.TestCase):
|
|
88
|
+
"""The override must do the job, not merely exist."""
|
|
89
|
+
|
|
90
|
+
class _Req:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def setUp(self):
|
|
94
|
+
proxy.PROXY_DISCONNECT_POLL_SECS = 0.05
|
|
95
|
+
|
|
96
|
+
def _client_with_send(self, behaviour):
|
|
97
|
+
"""A DisconnectAwareClient whose SUPERCLASS send is stubbed, so the
|
|
98
|
+
override under test runs for real."""
|
|
99
|
+
import httpx
|
|
100
|
+
|
|
101
|
+
state = {"cancelled": False}
|
|
102
|
+
|
|
103
|
+
async def fake_send(self, request, **kwargs):
|
|
104
|
+
if behaviour == "fast":
|
|
105
|
+
return "RESPONSE"
|
|
106
|
+
try:
|
|
107
|
+
await asyncio.sleep(3600)
|
|
108
|
+
except asyncio.CancelledError:
|
|
109
|
+
state["cancelled"] = True
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
original = httpx.AsyncClient.send
|
|
113
|
+
httpx.AsyncClient.send = fake_send # type: ignore[assignment]
|
|
114
|
+
client = proxy.DisconnectAwareClient()
|
|
115
|
+
return client, state, (lambda: setattr(httpx.AsyncClient, "send", original))
|
|
116
|
+
|
|
117
|
+
def _set_probe(self, gone: bool):
|
|
118
|
+
# The guard now reads a shared mutable holder published by the ASGI
|
|
119
|
+
# watcher, rather than calling back into the request object.
|
|
120
|
+
proxy._disconnect_holder.set({"gone": gone})
|
|
121
|
+
|
|
122
|
+
def test_cancels_upstream_when_the_caller_leaves(self):
|
|
123
|
+
client, state, restore = self._client_with_send("never")
|
|
124
|
+
try:
|
|
125
|
+
self._set_probe(True)
|
|
126
|
+
|
|
127
|
+
async def run():
|
|
128
|
+
with self.assertRaises(proxy.ClientGoneError):
|
|
129
|
+
await client.send(self._Req())
|
|
130
|
+
|
|
131
|
+
asyncio.run(run())
|
|
132
|
+
self.assertTrue(state["cancelled"], "upstream call was not cancelled")
|
|
133
|
+
finally:
|
|
134
|
+
restore()
|
|
135
|
+
|
|
136
|
+
def test_leaves_a_live_call_alone(self):
|
|
137
|
+
client, state, restore = self._client_with_send("fast")
|
|
138
|
+
try:
|
|
139
|
+
self._set_probe(False)
|
|
140
|
+
self.assertEqual(asyncio.run(client.send(self._Req())), "RESPONSE")
|
|
141
|
+
self.assertFalse(state["cancelled"])
|
|
142
|
+
finally:
|
|
143
|
+
restore()
|
|
144
|
+
|
|
145
|
+
def test_no_probe_means_unwatched(self):
|
|
146
|
+
"""Health checks and background tasks have no request context and must
|
|
147
|
+
keep working rather than acquiring surprise cancellation."""
|
|
148
|
+
client, state, restore = self._client_with_send("fast")
|
|
149
|
+
try:
|
|
150
|
+
proxy._disconnect_holder.set(None)
|
|
151
|
+
self.assertEqual(asyncio.run(client.send(self._Req())), "RESPONSE")
|
|
152
|
+
finally:
|
|
153
|
+
restore()
|
|
154
|
+
|
|
155
|
+
def test_a_slow_but_live_call_is_left_pending(self):
|
|
156
|
+
"""Mistaking 'slow' for 'gone' would be worse than the bug being fixed."""
|
|
157
|
+
client, state, restore = self._client_with_send("never")
|
|
158
|
+
try:
|
|
159
|
+
self._set_probe(False)
|
|
160
|
+
|
|
161
|
+
async def run():
|
|
162
|
+
task = asyncio.ensure_future(client.send(self._Req()))
|
|
163
|
+
await asyncio.sleep(0.4) # ~8 poll intervals
|
|
164
|
+
still = not task.done()
|
|
165
|
+
task.cancel()
|
|
166
|
+
try:
|
|
167
|
+
await task
|
|
168
|
+
except (asyncio.CancelledError, Exception):
|
|
169
|
+
pass
|
|
170
|
+
return still
|
|
171
|
+
|
|
172
|
+
self.assertTrue(asyncio.run(run()), "a live-but-slow call was ended early")
|
|
173
|
+
finally:
|
|
174
|
+
restore()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
unittest.main()
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Cancel the upstream generation when the caller hangs up mid-turn.
|
|
3
|
-
|
|
4
|
-
Checking only BEFORE each upstream call (v1.172.11) stops the retry loops, but
|
|
5
|
-
does nothing for a generation already in flight: a single turn is ONE post, so
|
|
6
|
-
there is no next call to block and the model runs to completion — up to
|
|
7
|
-
PROXY_TOOL_TURN_MAX_TOKENS (32k, ~13 minutes on this box) for a client that has
|
|
8
|
-
already gone.
|
|
9
|
-
|
|
10
|
-
That gap was caught by an end-to-end test, not by reasoning: the abandon simply
|
|
11
|
-
never fired for a single long request. These tests pin the closing behaviour so
|
|
12
|
-
it cannot silently regress to the pre-check-only version.
|
|
13
|
-
|
|
14
|
-
Cancelling matters rather than merely returning: closing the upstream connection
|
|
15
|
-
is what makes llama.cpp release the slot (verified against the running server —
|
|
16
|
-
slots went 2 -> 0 within 15s of a raw socket close).
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
import asyncio
|
|
20
|
-
import importlib.util
|
|
21
|
-
import unittest
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _load_proxy():
|
|
26
|
-
p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
27
|
-
spec = importlib.util.spec_from_file_location("anthropic_proxy_inflight", p)
|
|
28
|
-
assert spec is not None and spec.loader is not None
|
|
29
|
-
m = importlib.util.module_from_spec(spec)
|
|
30
|
-
spec.loader.exec_module(m)
|
|
31
|
-
return m
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
proxy = _load_proxy()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class _Client:
|
|
38
|
-
"""Stands in for httpx.AsyncClient.post.
|
|
39
|
-
|
|
40
|
-
`never` models the real failure: the model is generating, so the POST is
|
|
41
|
-
neither erroring nor returning — it is simply not done yet.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
def __init__(self, mode: str):
|
|
45
|
-
self.mode = mode
|
|
46
|
-
self.cancelled = False
|
|
47
|
-
|
|
48
|
-
async def post(self, url, json=None, headers=None): # noqa: A002
|
|
49
|
-
if self.mode == "fast":
|
|
50
|
-
return "RESPONSE"
|
|
51
|
-
try:
|
|
52
|
-
await asyncio.sleep(3600)
|
|
53
|
-
except asyncio.CancelledError:
|
|
54
|
-
self.cancelled = True
|
|
55
|
-
raise
|
|
56
|
-
return "RESPONSE"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _set_probe(gone: bool):
|
|
60
|
-
async def probe() -> bool:
|
|
61
|
-
return gone
|
|
62
|
-
proxy._current_client_gone.set(probe)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class InflightCancelTest(unittest.TestCase):
|
|
66
|
-
def setUp(self):
|
|
67
|
-
proxy.PROXY_DISCONNECT_POLL_SECS = 0.05 # keep the tests quick
|
|
68
|
-
|
|
69
|
-
def test_cancels_the_upstream_call_when_the_caller_leaves(self):
|
|
70
|
-
client = _Client("never")
|
|
71
|
-
_set_probe(True)
|
|
72
|
-
|
|
73
|
-
async def run():
|
|
74
|
-
with self.assertRaises(proxy.ClientGoneError):
|
|
75
|
-
await proxy._post_watching_client(client, "u", {}, {})
|
|
76
|
-
|
|
77
|
-
asyncio.run(run())
|
|
78
|
-
# The cancellation is the whole point: it closes the connection, which is
|
|
79
|
-
# what frees the llama slot. Returning early without it would leave the
|
|
80
|
-
# model generating exactly as before.
|
|
81
|
-
self.assertTrue(client.cancelled, "upstream POST was not cancelled")
|
|
82
|
-
|
|
83
|
-
def test_leaves_a_live_turn_completely_alone(self):
|
|
84
|
-
client = _Client("fast")
|
|
85
|
-
_set_probe(False)
|
|
86
|
-
self.assertEqual(asyncio.run(proxy._post_watching_client(client, "u", {}, {})), "RESPONSE")
|
|
87
|
-
self.assertFalse(client.cancelled)
|
|
88
|
-
|
|
89
|
-
def test_no_probe_means_a_plain_await(self):
|
|
90
|
-
"""Background tasks and tests have no request context. They must keep
|
|
91
|
-
working, unwatched, rather than acquiring surprise cancellation."""
|
|
92
|
-
client = _Client("fast")
|
|
93
|
-
proxy._current_client_gone.set(None)
|
|
94
|
-
self.assertEqual(asyncio.run(proxy._post_watching_client(client, "u", {}, {})), "RESPONSE")
|
|
95
|
-
|
|
96
|
-
def test_a_slow_but_live_turn_is_left_pending(self):
|
|
97
|
-
"""The poll must not mistake 'slow' for 'gone' — long generations are
|
|
98
|
-
normal, and cancelling them would be a far worse bug than the one fixed.
|
|
99
|
-
|
|
100
|
-
Asserted by letting it poll many times and checking it is STILL running.
|
|
101
|
-
(Wrapping it in wait_for would prove nothing: a wait_for timeout is the
|
|
102
|
-
caller giving up, and the cleanup correctly cancels upstream then — which
|
|
103
|
-
is how the first version of this test fooled itself.)
|
|
104
|
-
"""
|
|
105
|
-
client = _Client("never")
|
|
106
|
-
_set_probe(False)
|
|
107
|
-
|
|
108
|
-
async def run():
|
|
109
|
-
task = asyncio.ensure_future(proxy._post_watching_client(client, "u", {}, {}))
|
|
110
|
-
await asyncio.sleep(0.4) # ~8 poll intervals
|
|
111
|
-
still_running = not task.done()
|
|
112
|
-
task.cancel()
|
|
113
|
-
try:
|
|
114
|
-
await task
|
|
115
|
-
except (asyncio.CancelledError, Exception):
|
|
116
|
-
pass
|
|
117
|
-
return still_running
|
|
118
|
-
|
|
119
|
-
self.assertTrue(asyncio.run(run()), "a live-but-slow turn was ended early")
|
|
120
|
-
|
|
121
|
-
def test_poll_interval_has_a_floor(self):
|
|
122
|
-
"""A zero/negative interval would spin the event loop."""
|
|
123
|
-
self.assertGreaterEqual(proxy.PROXY_DISCONNECT_POLL_SECS, 0.0)
|
|
124
|
-
mod = _load_proxy()
|
|
125
|
-
self.assertGreaterEqual(mod.PROXY_DISCONNECT_POLL_SECS, 0.5)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if __name__ == "__main__":
|
|
129
|
-
unittest.main()
|