@miller-tech/uap 1.172.12 → 1.172.13
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 +42 -39
- 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)
|
|
@@ -3364,47 +3364,50 @@ PROXY_DISCONNECT_POLL_SECS = max(
|
|
|
3364
3364
|
)
|
|
3365
3365
|
|
|
3366
3366
|
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
) -> httpx.Response:
|
|
3370
|
-
"""POST upstream, cancelling it if the caller hangs up mid-generation.
|
|
3367
|
+
class DisconnectAwareClient(httpx.AsyncClient):
|
|
3368
|
+
"""An httpx client that abandons a call when the caller hangs up.
|
|
3371
3369
|
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3370
|
+
THE CHOKE POINT. The previous attempt guarded ONE call site and claimed to
|
|
3371
|
+
cover them all; there are fourteen, and the guardrail loops that caused the
|
|
3372
|
+
incident call the model directly rather than through _post_with_retry. So
|
|
3373
|
+
the check belongs where it cannot be missed: every high-level httpx method —
|
|
3374
|
+
post(), stream(), request() — funnels through send(), so overriding send()
|
|
3375
|
+
covers all of them, including any added later.
|
|
3377
3376
|
|
|
3378
|
-
Cancelling
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3377
|
+
Cancelling matters rather than merely returning: closing the connection is
|
|
3378
|
+
what makes llama.cpp release the slot (verified against the running server —
|
|
3379
|
+
slots went 2 -> 0 within 15s of a raw socket close). Returning early without
|
|
3380
|
+
cancelling would leave the model generating exactly as before.
|
|
3382
3381
|
|
|
3383
|
-
No probe (background task, tests) means
|
|
3382
|
+
No probe (background task, health check, tests) means a plain send,
|
|
3383
|
+
unwatched. Streaming sends are covered for their header phase; a body being
|
|
3384
|
+
written to a vanished client fails on write anyway.
|
|
3384
3385
|
"""
|
|
3385
|
-
probe = _current_client_gone.get()
|
|
3386
|
-
if probe is None:
|
|
3387
|
-
return await client.post(url, json=payload, headers=headers)
|
|
3388
3386
|
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3387
|
+
async def send(self, request, **kwargs): # type: ignore[override]
|
|
3388
|
+
probe = _current_client_gone.get()
|
|
3389
|
+
if probe is None:
|
|
3390
|
+
return await super().send(request, **kwargs)
|
|
3391
|
+
|
|
3392
|
+
task = asyncio.ensure_future(super().send(request, **kwargs))
|
|
3393
|
+
try:
|
|
3394
|
+
while True:
|
|
3395
|
+
done, _pending = await asyncio.wait(
|
|
3396
|
+
{task}, timeout=PROXY_DISCONNECT_POLL_SECS
|
|
3397
|
+
)
|
|
3398
|
+
if task in done:
|
|
3399
|
+
return task.result()
|
|
3400
|
+
if await _client_gone():
|
|
3401
|
+
task.cancel()
|
|
3402
|
+
try:
|
|
3403
|
+
await task
|
|
3404
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
3405
|
+
pass # cancellation is the point; the connection is closed
|
|
3406
|
+
raise ClientGoneError("client disconnected mid-generation")
|
|
3407
|
+
finally:
|
|
3408
|
+
# Never leave an upstream call running once we stop waiting on it.
|
|
3409
|
+
if not task.done():
|
|
3410
|
+
task.cancel()
|
|
3408
3411
|
|
|
3409
3412
|
|
|
3410
3413
|
async def _post_with_retry_inner(
|
|
@@ -3418,7 +3421,7 @@ async def _post_with_retry_inner(
|
|
|
3418
3421
|
try:
|
|
3419
3422
|
_inflight_inc(client)
|
|
3420
3423
|
try:
|
|
3421
|
-
resp = await
|
|
3424
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
3422
3425
|
finally:
|
|
3423
3426
|
_inflight_dec(client)
|
|
3424
3427
|
# Cycle 19 Option 1: if 503 "Loading model", wait for health then retry
|
|
@@ -3495,7 +3498,7 @@ async def _check_slot_hang(slot_url: str) -> bool:
|
|
|
3495
3498
|
if PROXY_SLOT_HANG_TIMEOUT <= 0:
|
|
3496
3499
|
return False
|
|
3497
3500
|
try:
|
|
3498
|
-
async with
|
|
3501
|
+
async with DisconnectAwareClient() as check_client:
|
|
3499
3502
|
resp = await check_client.get(slot_url, timeout=5.0)
|
|
3500
3503
|
if resp.status_code != 200:
|
|
3501
3504
|
return False
|
|
@@ -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
|
+
async def probe() -> bool:
|
|
119
|
+
return gone
|
|
120
|
+
proxy._current_client_gone.set(probe)
|
|
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._current_client_gone.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()
|