@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.172.11",
3
+ "version": "1.172.12",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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.post(url, json=payload, headers=headers)
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()