@miller-tech/uap 1.172.11 → 1.172.12
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 +50 -1
- package/tools/agents/tests/test_inflight_cancel.py +129 -0
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -3358,6 +3358,55 @@ async def _post_with_retry(
|
|
|
3358
3358
|
_release_upstream_slot()
|
|
3359
3359
|
|
|
3360
3360
|
|
|
3361
|
+
# How often to re-check the caller while an upstream generation is in flight.
|
|
3362
|
+
PROXY_DISCONNECT_POLL_SECS = max(
|
|
3363
|
+
0.5, float(os.environ.get("PROXY_DISCONNECT_POLL_SECS", "2"))
|
|
3364
|
+
)
|
|
3365
|
+
|
|
3366
|
+
|
|
3367
|
+
async def _post_watching_client(
|
|
3368
|
+
client: httpx.AsyncClient, url: str, payload: dict, headers: dict
|
|
3369
|
+
) -> httpx.Response:
|
|
3370
|
+
"""POST upstream, cancelling it if the caller hangs up mid-generation.
|
|
3371
|
+
|
|
3372
|
+
Checking only BEFORE each upstream call stops the retry loops, but does
|
|
3373
|
+
nothing for a generation already in flight: a single turn is one POST, so
|
|
3374
|
+
there is no next call to block and the model runs to completion — up to
|
|
3375
|
+
PROXY_TOOL_TURN_MAX_TOKENS (32k, ~13 minutes here) for a client that has
|
|
3376
|
+
already gone. This closes that.
|
|
3377
|
+
|
|
3378
|
+
Cancelling the httpx task closes the upstream connection, and llama.cpp
|
|
3379
|
+
releases the slot when its client disappears — verified directly against the
|
|
3380
|
+
running server: slots went 2 -> 0 within 15s of closing the socket. So this
|
|
3381
|
+
genuinely frees the GPU rather than merely letting the proxy stop waiting.
|
|
3382
|
+
|
|
3383
|
+
No probe (background task, tests) means no watching: plain await, unchanged.
|
|
3384
|
+
"""
|
|
3385
|
+
probe = _current_client_gone.get()
|
|
3386
|
+
if probe is None:
|
|
3387
|
+
return await client.post(url, json=payload, headers=headers)
|
|
3388
|
+
|
|
3389
|
+
post_task = asyncio.ensure_future(client.post(url, json=payload, headers=headers))
|
|
3390
|
+
try:
|
|
3391
|
+
while True:
|
|
3392
|
+
done, _pending = await asyncio.wait(
|
|
3393
|
+
{post_task}, timeout=PROXY_DISCONNECT_POLL_SECS
|
|
3394
|
+
)
|
|
3395
|
+
if post_task in done:
|
|
3396
|
+
return post_task.result()
|
|
3397
|
+
if await _client_gone():
|
|
3398
|
+
post_task.cancel()
|
|
3399
|
+
try:
|
|
3400
|
+
await post_task
|
|
3401
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
3402
|
+
pass # cancellation is the point; the connection is closed
|
|
3403
|
+
raise ClientGoneError("client disconnected mid-generation")
|
|
3404
|
+
finally:
|
|
3405
|
+
# Never leave the upstream call running after we stop waiting on it.
|
|
3406
|
+
if not post_task.done():
|
|
3407
|
+
post_task.cancel()
|
|
3408
|
+
|
|
3409
|
+
|
|
3361
3410
|
async def _post_with_retry_inner(
|
|
3362
3411
|
client: httpx.AsyncClient,
|
|
3363
3412
|
url: str,
|
|
@@ -3369,7 +3418,7 @@ async def _post_with_retry_inner(
|
|
|
3369
3418
|
try:
|
|
3370
3419
|
_inflight_inc(client)
|
|
3371
3420
|
try:
|
|
3372
|
-
resp = await client
|
|
3421
|
+
resp = await _post_watching_client(client, url, payload, headers)
|
|
3373
3422
|
finally:
|
|
3374
3423
|
_inflight_dec(client)
|
|
3375
3424
|
# Cycle 19 Option 1: if 503 "Loading model", wait for health then retry
|
|
@@ -0,0 +1,129 @@
|
|
|
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()
|