@miller-tech/uap 1.76.3 → 1.76.4
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
|
Binary file
|
|
@@ -1851,6 +1851,50 @@ PROXY_CONCURRENCY_QUEUE_TIMEOUT = float(
|
|
|
1851
1851
|
upstream_semaphore: asyncio.Semaphore | None = None
|
|
1852
1852
|
|
|
1853
1853
|
|
|
1854
|
+
# ---------------------------------------------------------------------------
|
|
1855
|
+
# Session admission control — cap the number of DISTINCT "hot" sessions
|
|
1856
|
+
# ---------------------------------------------------------------------------
|
|
1857
|
+
# The semaphore above limits CONCURRENT REQUESTS. On a multi-slot llama.cpp
|
|
1858
|
+
# (--parallel N) llama's native prompt-cache keeps each session on its own slot
|
|
1859
|
+
# across turns — but only N slots exist. When MORE than N distinct sessions are
|
|
1860
|
+
# active, a returning session finds its slot reassigned to another session, its
|
|
1861
|
+
# KV gone, forcing a from-scratch reprocess of the whole context (brutal on an
|
|
1862
|
+
# SSM/Mamba model, which cannot restore partial KV: every eviction is a full
|
|
1863
|
+
# prefill). The per-request semaphore can't prevent this — it lets session A
|
|
1864
|
+
# finish + release, then admits session E, which evicts A's slot.
|
|
1865
|
+
#
|
|
1866
|
+
# Session admission caps the number of DISTINCT sessions holding a slot at once.
|
|
1867
|
+
# A new session over the limit WAITS (queues) until an admitted session goes
|
|
1868
|
+
# idle (no request for IDLE_TTL) and is pruned, instead of barging in and
|
|
1869
|
+
# evicting a hot session. Admission is STICKY across a session's turns; it is
|
|
1870
|
+
# released only by idle-TTL expiry (or, under sustained over-subscription, a
|
|
1871
|
+
# wait-timeout graceful-degrade that force-admits, evicting the LRU). Default
|
|
1872
|
+
# OFF — opt in via PROXY_SESSION_ADMISSION=on (set the LIMIT to the llama slot
|
|
1873
|
+
# count / --parallel value).
|
|
1874
|
+
PROXY_SESSION_ADMISSION = os.environ.get(
|
|
1875
|
+
"PROXY_SESSION_ADMISSION", "off"
|
|
1876
|
+
).lower() not in {"", "0", "off", "false", "no"}
|
|
1877
|
+
# Max distinct hot sessions. Default = the concurrency limit (= llama slots).
|
|
1878
|
+
PROXY_SESSION_ADMISSION_LIMIT = int(
|
|
1879
|
+
os.environ.get("PROXY_SESSION_ADMISSION_LIMIT", str(PROXY_CONCURRENCY_LIMIT))
|
|
1880
|
+
)
|
|
1881
|
+
# Seconds a session may be idle (no request) before its admission is pruned,
|
|
1882
|
+
# freeing a slot for a waiting session. Tune ABOVE typical inter-turn gaps so an
|
|
1883
|
+
# actively-working session is never pruned mid-task. 0 = never prune on idle.
|
|
1884
|
+
PROXY_SESSION_ADMISSION_IDLE_TTL = float(
|
|
1885
|
+
os.environ.get("PROXY_SESSION_ADMISSION_IDLE_TTL", "90")
|
|
1886
|
+
)
|
|
1887
|
+
# Max seconds a new session waits for admission before graceful-degrade
|
|
1888
|
+
# (force-admit, evicting the LRU admitted session). 0 = wait indefinitely.
|
|
1889
|
+
PROXY_SESSION_ADMISSION_WAIT_TIMEOUT = float(
|
|
1890
|
+
os.environ.get("PROXY_SESSION_ADMISSION_WAIT_TIMEOUT", "300")
|
|
1891
|
+
)
|
|
1892
|
+
# How often a waiter re-checks for a freed slot (re-prunes idle admissions).
|
|
1893
|
+
PROXY_SESSION_ADMISSION_POLL = float(
|
|
1894
|
+
os.environ.get("PROXY_SESSION_ADMISSION_POLL", "3")
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
|
|
1854
1898
|
# ---------------------------------------------------------------------------
|
|
1855
1899
|
# Slot save/restore — cross-session KV-cache preservation
|
|
1856
1900
|
# ---------------------------------------------------------------------------
|
|
@@ -1902,6 +1946,12 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
|
|
|
1902
1946
|
"uap_current_request_session", default=None
|
|
1903
1947
|
)
|
|
1904
1948
|
|
|
1949
|
+
# Session admission state. _admitted_sessions maps session_id -> last-seen
|
|
1950
|
+
# monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
|
|
1951
|
+
# Guarded by _admission_cond's lock (created lazily on the running event loop).
|
|
1952
|
+
_admitted_sessions: "OrderedDict[str, float]" = OrderedDict()
|
|
1953
|
+
_admission_cond: "asyncio.Condition | None" = None
|
|
1954
|
+
|
|
1905
1955
|
|
|
1906
1956
|
def _slot_endpoint_base() -> str:
|
|
1907
1957
|
"""Base URL for llama-server's /slots endpoint (LLAMA_CPP_BASE without /v1)."""
|
|
@@ -2052,6 +2102,93 @@ def _prepare_slot_save_dir() -> None:
|
|
|
2052
2102
|
logger.warning("SLOT SAVE/RESTORE: startup dir prep failed: %s", exc)
|
|
2053
2103
|
|
|
2054
2104
|
|
|
2105
|
+
def _prune_idle_admissions(now: float) -> list[str]:
|
|
2106
|
+
"""Remove admitted sessions idle longer than the TTL; return their ids.
|
|
2107
|
+
Caller must hold the admission lock. 0 TTL disables idle pruning."""
|
|
2108
|
+
ttl = PROXY_SESSION_ADMISSION_IDLE_TTL
|
|
2109
|
+
if ttl <= 0:
|
|
2110
|
+
return []
|
|
2111
|
+
stale = [sid for sid, ts in _admitted_sessions.items() if now - ts > ttl]
|
|
2112
|
+
for sid in stale:
|
|
2113
|
+
_admitted_sessions.pop(sid, None)
|
|
2114
|
+
return stale
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def _try_admit_session(session_id: str, now: float) -> bool:
|
|
2118
|
+
"""Synchronous admission core (caller holds the admission lock). Returns
|
|
2119
|
+
True if the session is admitted — already hot (refreshed) or newly admitted
|
|
2120
|
+
into a free slot — False if the hot set is full. Pure/deterministic given
|
|
2121
|
+
`now`, so it is unit-tested directly."""
|
|
2122
|
+
_prune_idle_admissions(now)
|
|
2123
|
+
if session_id in _admitted_sessions:
|
|
2124
|
+
_admitted_sessions[session_id] = now
|
|
2125
|
+
_admitted_sessions.move_to_end(session_id)
|
|
2126
|
+
return True
|
|
2127
|
+
if len(_admitted_sessions) < PROXY_SESSION_ADMISSION_LIMIT:
|
|
2128
|
+
_admitted_sessions[session_id] = now
|
|
2129
|
+
_admitted_sessions.move_to_end(session_id)
|
|
2130
|
+
return True
|
|
2131
|
+
return False
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
async def _ensure_session_admitted(session_id: str | None) -> None:
|
|
2135
|
+
"""Block until `session_id` is admitted to the hot set (size <= LIMIT).
|
|
2136
|
+
|
|
2137
|
+
Sticky: admission persists across a session's turns and is released only by
|
|
2138
|
+
idle-TTL pruning. A new session over the limit queues (waits) rather than
|
|
2139
|
+
evicting a hot session. On wait-timeout it force-admits, evicting the LRU,
|
|
2140
|
+
to avoid a hard stall (graceful degrade to pre-admission behaviour).
|
|
2141
|
+
No-op when disabled or when there is no session id."""
|
|
2142
|
+
global _admission_cond
|
|
2143
|
+
if not PROXY_SESSION_ADMISSION or not session_id:
|
|
2144
|
+
return
|
|
2145
|
+
if _admission_cond is None:
|
|
2146
|
+
_admission_cond = asyncio.Condition()
|
|
2147
|
+
cond = _admission_cond
|
|
2148
|
+
deadline = (
|
|
2149
|
+
time.monotonic() + PROXY_SESSION_ADMISSION_WAIT_TIMEOUT
|
|
2150
|
+
if PROXY_SESSION_ADMISSION_WAIT_TIMEOUT > 0
|
|
2151
|
+
else None
|
|
2152
|
+
)
|
|
2153
|
+
waited = False
|
|
2154
|
+
async with cond:
|
|
2155
|
+
while True:
|
|
2156
|
+
now = time.monotonic()
|
|
2157
|
+
if _try_admit_session(session_id, now):
|
|
2158
|
+
if waited:
|
|
2159
|
+
logger.info(
|
|
2160
|
+
"SESSION ADMISSION: admitted %s after wait (%d/%d hot)",
|
|
2161
|
+
session_id[:12], len(_admitted_sessions),
|
|
2162
|
+
PROXY_SESSION_ADMISSION_LIMIT,
|
|
2163
|
+
)
|
|
2164
|
+
# A refresh/new admit may have pruned idle sessions; wake peers.
|
|
2165
|
+
cond.notify_all()
|
|
2166
|
+
return
|
|
2167
|
+
# Hot set full and this session isn't in it.
|
|
2168
|
+
if deadline is not None and now >= deadline:
|
|
2169
|
+
evicted = "-"
|
|
2170
|
+
if _admitted_sessions:
|
|
2171
|
+
evicted = next(iter(_admitted_sessions)) # LRU = oldest front
|
|
2172
|
+
_admitted_sessions.pop(evicted, None)
|
|
2173
|
+
_admitted_sessions[session_id] = now
|
|
2174
|
+
logger.warning(
|
|
2175
|
+
"SESSION ADMISSION: wait timeout (%ds), force-admitted %s "
|
|
2176
|
+
"(evicted LRU %s) — sustained over-subscription (>%d hot sessions)",
|
|
2177
|
+
int(PROXY_SESSION_ADMISSION_WAIT_TIMEOUT), session_id[:12],
|
|
2178
|
+
evicted[:12], PROXY_SESSION_ADMISSION_LIMIT,
|
|
2179
|
+
)
|
|
2180
|
+
cond.notify_all()
|
|
2181
|
+
return
|
|
2182
|
+
waited = True
|
|
2183
|
+
timeout = PROXY_SESSION_ADMISSION_POLL
|
|
2184
|
+
if deadline is not None:
|
|
2185
|
+
timeout = max(0.0, min(timeout, deadline - now))
|
|
2186
|
+
try:
|
|
2187
|
+
await asyncio.wait_for(cond.wait(), timeout=timeout)
|
|
2188
|
+
except asyncio.TimeoutError:
|
|
2189
|
+
pass # re-loop: re-prune idle admissions, retry
|
|
2190
|
+
|
|
2191
|
+
|
|
2055
2192
|
async def _acquire_upstream_slot() -> bool:
|
|
2056
2193
|
"""Acquire a semaphore slot for an upstream request.
|
|
2057
2194
|
|
|
@@ -2136,6 +2273,11 @@ async def _post_with_retry(
|
|
|
2136
2273
|
rather than all hammering llama.cpp at once. Slot is released in a
|
|
2137
2274
|
finally block so it's always returned to the pool even on error.
|
|
2138
2275
|
"""
|
|
2276
|
+
# Session admission: cap DISTINCT hot sessions to <= the slot count so they
|
|
2277
|
+
# don't evict each other's KV (sticky, released by idle-TTL). No-op when
|
|
2278
|
+
# PROXY_SESSION_ADMISSION is off. Runs BEFORE the per-request semaphore so a
|
|
2279
|
+
# queued new session waits without holding a concurrency slot.
|
|
2280
|
+
await _ensure_session_admitted(_current_request_session.get())
|
|
2139
2281
|
acquired = await _acquire_upstream_slot()
|
|
2140
2282
|
if not acquired:
|
|
2141
2283
|
logger.warning(
|
|
@@ -2272,7 +2414,19 @@ async def lifespan(app: FastAPI):
|
|
|
2272
2414
|
global http_client
|
|
2273
2415
|
global default_context_window
|
|
2274
2416
|
global upstream_semaphore
|
|
2417
|
+
global _admission_cond
|
|
2275
2418
|
upstream_semaphore = asyncio.Semaphore(PROXY_CONCURRENCY_LIMIT)
|
|
2419
|
+
# Bind the admission condition to THIS event loop and clear stale state.
|
|
2420
|
+
_admission_cond = asyncio.Condition()
|
|
2421
|
+
_admitted_sessions.clear()
|
|
2422
|
+
if PROXY_SESSION_ADMISSION:
|
|
2423
|
+
logger.info(
|
|
2424
|
+
"SESSION ADMISSION: on (limit=%d hot sessions, idle_ttl=%.0fs, "
|
|
2425
|
+
"wait_timeout=%.0fs)",
|
|
2426
|
+
PROXY_SESSION_ADMISSION_LIMIT,
|
|
2427
|
+
PROXY_SESSION_ADMISSION_IDLE_TTL,
|
|
2428
|
+
PROXY_SESSION_ADMISSION_WAIT_TIMEOUT,
|
|
2429
|
+
)
|
|
2276
2430
|
logger.info(
|
|
2277
2431
|
"CONCURRENCY: upstream semaphore initialized limit=%d queue_timeout=%.0fs",
|
|
2278
2432
|
PROXY_CONCURRENCY_LIMIT,
|
|
@@ -2369,6 +2523,8 @@ async def lifespan(app: FastAPI):
|
|
|
2369
2523
|
http_client = None
|
|
2370
2524
|
if upstream_semaphore is not None:
|
|
2371
2525
|
upstream_semaphore = None
|
|
2526
|
+
_admission_cond = None
|
|
2527
|
+
_admitted_sessions.clear()
|
|
2372
2528
|
logger.info("Proxy shut down")
|
|
2373
2529
|
|
|
2374
2530
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Session-admission control: cap the number of DISTINCT hot sessions to <= the
|
|
3
|
+
slot count so they don't evict each other's KV.
|
|
4
|
+
|
|
5
|
+
The per-request semaphore limits CONCURRENT REQUESTS; admission limits DISTINCT
|
|
6
|
+
SESSIONS. A new session over the limit queues until an admitted session goes
|
|
7
|
+
idle (TTL) and is pruned, instead of barging in and evicting a hot session
|
|
8
|
+
(every eviction = a full reprocess on the SSM model). Admission is sticky across
|
|
9
|
+
a session's turns; on a wait-timeout it force-admits (graceful degrade).
|
|
10
|
+
|
|
11
|
+
The synchronous core `_try_admit_session` / `_prune_idle_admissions` is pure
|
|
12
|
+
given `now`, so it's tested directly. The async wrapper is tested for the
|
|
13
|
+
no-op-when-disabled and force-admit-on-timeout paths.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import importlib.util
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load():
|
|
23
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
|
|
25
|
+
m = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(m)
|
|
27
|
+
return m
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
proxy = _load()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestAdmissionCore(unittest.TestCase):
|
|
34
|
+
def setUp(self):
|
|
35
|
+
proxy._admitted_sessions.clear()
|
|
36
|
+
proxy.PROXY_SESSION_ADMISSION_LIMIT = 2
|
|
37
|
+
proxy.PROXY_SESSION_ADMISSION_IDLE_TTL = 90.0
|
|
38
|
+
|
|
39
|
+
def test_admit_up_to_limit_then_full(self):
|
|
40
|
+
self.assertTrue(proxy._try_admit_session("s1", now=0))
|
|
41
|
+
self.assertTrue(proxy._try_admit_session("s2", now=1))
|
|
42
|
+
# 3rd distinct session: hot set full -> rejected
|
|
43
|
+
self.assertFalse(proxy._try_admit_session("s3", now=2))
|
|
44
|
+
self.assertEqual(set(proxy._admitted_sessions), {"s1", "s2"})
|
|
45
|
+
|
|
46
|
+
def test_already_hot_session_refreshes_and_is_admitted(self):
|
|
47
|
+
proxy._try_admit_session("s1", now=0)
|
|
48
|
+
proxy._try_admit_session("s2", now=1)
|
|
49
|
+
# s1 returns — must stay admitted (no eviction of itself) and refresh ts
|
|
50
|
+
self.assertTrue(proxy._try_admit_session("s1", now=50))
|
|
51
|
+
self.assertEqual(proxy._admitted_sessions["s1"], 50)
|
|
52
|
+
|
|
53
|
+
def test_idle_session_pruned_after_ttl_frees_a_slot(self):
|
|
54
|
+
proxy._try_admit_session("s1", now=0)
|
|
55
|
+
proxy._try_admit_session("s2", now=1)
|
|
56
|
+
self.assertFalse(proxy._try_admit_session("s3", now=2)) # full
|
|
57
|
+
# advance past TTL: s1 (ts0) and s2 (ts1) are now idle > 90s -> pruned,
|
|
58
|
+
# s3 admits into the freed slot
|
|
59
|
+
self.assertTrue(proxy._try_admit_session("s3", now=200))
|
|
60
|
+
self.assertIn("s3", proxy._admitted_sessions)
|
|
61
|
+
self.assertNotIn("s1", proxy._admitted_sessions)
|
|
62
|
+
|
|
63
|
+
def test_zero_ttl_disables_idle_pruning(self):
|
|
64
|
+
proxy.PROXY_SESSION_ADMISSION_IDLE_TTL = 0.0
|
|
65
|
+
proxy._try_admit_session("s1", now=0)
|
|
66
|
+
proxy._try_admit_session("s2", now=1)
|
|
67
|
+
# even far in the future, no idle prune -> s3 stays blocked
|
|
68
|
+
self.assertFalse(proxy._try_admit_session("s3", now=10_000))
|
|
69
|
+
|
|
70
|
+
def test_lru_is_front_of_ordereddict(self):
|
|
71
|
+
proxy._try_admit_session("s1", now=0)
|
|
72
|
+
proxy._try_admit_session("s2", now=1)
|
|
73
|
+
proxy._try_admit_session("s1", now=5) # refresh moves s1 to back
|
|
74
|
+
# s2 is now the LRU (front) -> first to be force-evicted
|
|
75
|
+
self.assertEqual(next(iter(proxy._admitted_sessions)), "s2")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestAdmissionAsync(unittest.TestCase):
|
|
79
|
+
def setUp(self):
|
|
80
|
+
proxy._admitted_sessions.clear()
|
|
81
|
+
proxy._admission_cond = None
|
|
82
|
+
|
|
83
|
+
def test_noop_when_disabled(self):
|
|
84
|
+
proxy.PROXY_SESSION_ADMISSION = False
|
|
85
|
+
asyncio.run(proxy._ensure_session_admitted("anything"))
|
|
86
|
+
self.assertEqual(len(proxy._admitted_sessions), 0) # never tracked
|
|
87
|
+
|
|
88
|
+
def test_noop_when_no_session_id(self):
|
|
89
|
+
proxy.PROXY_SESSION_ADMISSION = True
|
|
90
|
+
asyncio.run(proxy._ensure_session_admitted(None))
|
|
91
|
+
self.assertEqual(len(proxy._admitted_sessions), 0)
|
|
92
|
+
|
|
93
|
+
def test_admits_when_room(self):
|
|
94
|
+
proxy.PROXY_SESSION_ADMISSION = True
|
|
95
|
+
proxy.PROXY_SESSION_ADMISSION_LIMIT = 2
|
|
96
|
+
asyncio.run(proxy._ensure_session_admitted("s1"))
|
|
97
|
+
self.assertIn("s1", proxy._admitted_sessions)
|
|
98
|
+
|
|
99
|
+
def test_force_admit_on_wait_timeout(self):
|
|
100
|
+
# Over-subscribed + no idle prune + ~0 wait timeout -> force-admit,
|
|
101
|
+
# evicting the LRU (graceful degrade, never hard-stalls).
|
|
102
|
+
proxy.PROXY_SESSION_ADMISSION = True
|
|
103
|
+
proxy.PROXY_SESSION_ADMISSION_LIMIT = 1
|
|
104
|
+
proxy.PROXY_SESSION_ADMISSION_IDLE_TTL = 0.0 # never prune
|
|
105
|
+
proxy.PROXY_SESSION_ADMISSION_WAIT_TIMEOUT = 0.01
|
|
106
|
+
proxy.PROXY_SESSION_ADMISSION_POLL = 0.01
|
|
107
|
+
|
|
108
|
+
async def run():
|
|
109
|
+
await proxy._ensure_session_admitted("hot") # fills the 1 slot
|
|
110
|
+
await proxy._ensure_session_admitted("waiter") # times out -> force
|
|
111
|
+
asyncio.run(run())
|
|
112
|
+
self.assertIn("waiter", proxy._admitted_sessions)
|
|
113
|
+
self.assertNotIn("hot", proxy._admitted_sessions) # LRU evicted
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|