@miller-tech/uap 1.136.1 → 1.137.1
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/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/paired/report.d.ts +13 -0
- package/dist/benchmarks/paired/report.d.ts.map +1 -1
- package/dist/benchmarks/paired/report.js +29 -3
- package/dist/benchmarks/paired/report.js.map +1 -1
- package/dist/benchmarks/paired/types.d.ts +59 -0
- package/dist/benchmarks/paired/types.d.ts.map +1 -1
- package/dist/benchmarks/paired/types.js +60 -0
- package/dist/benchmarks/paired/types.js.map +1 -1
- package/dist/bin/cli.js +30 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +111 -7
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/react.d.ts.map +1 -1
- package/dist/cli/react.js +18 -0
- package/dist/cli/react.js.map +1 -1
- package/dist/cli/self-tuning.d.ts +33 -0
- package/dist/cli/self-tuning.d.ts.map +1 -0
- package/dist/cli/self-tuning.js +139 -0
- package/dist/cli/self-tuning.js.map +1 -0
- package/dist/cli/tune-adapter.d.ts +8 -0
- package/dist/cli/tune-adapter.d.ts.map +1 -0
- package/dist/cli/tune-adapter.js +26 -0
- package/dist/cli/tune-adapter.js.map +1 -0
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +10 -0
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/adaptation-signal.d.ts +19 -0
- package/dist/coordination/adaptation-signal.d.ts.map +1 -0
- package/dist/coordination/adaptation-signal.js +36 -0
- package/dist/coordination/adaptation-signal.js.map +1 -0
- package/dist/delivery/agentic-executor.d.ts +16 -1
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +45 -4
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/decompose.d.ts +27 -0
- package/dist/delivery/decompose.d.ts.map +1 -1
- package/dist/delivery/decompose.js +31 -1
- package/dist/delivery/decompose.js.map +1 -1
- package/dist/delivery/repair-escalation.d.ts +49 -0
- package/dist/delivery/repair-escalation.d.ts.map +1 -0
- package/dist/delivery/repair-escalation.js +83 -0
- package/dist/delivery/repair-escalation.js.map +1 -0
- package/dist/delivery/verifier-ladder.d.ts +47 -0
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +87 -1
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/dist/self-harness/mods.d.ts +20 -1
- package/dist/self-harness/mods.d.ts.map +1 -1
- package/dist/self-harness/mods.js +41 -0
- package/dist/self-harness/mods.js.map +1 -1
- package/dist/self-harness/orchestrator.js +4 -0
- package/dist/self-harness/orchestrator.js.map +1 -1
- package/dist/self-harness/transfer.d.ts.map +1 -1
- package/dist/self-harness/transfer.js +2 -0
- package/dist/self-harness/transfer.js.map +1 -1
- package/dist/self-tuning/flag-writer.d.ts +48 -0
- package/dist/self-tuning/flag-writer.d.ts.map +1 -0
- package/dist/self-tuning/flag-writer.js +102 -0
- package/dist/self-tuning/flag-writer.js.map +1 -0
- package/dist/self-tuning/flags.d.ts +88 -0
- package/dist/self-tuning/flags.d.ts.map +1 -0
- package/dist/self-tuning/flags.js +189 -0
- package/dist/self-tuning/flags.js.map +1 -0
- package/dist/self-tuning/index.d.ts +25 -0
- package/dist/self-tuning/index.d.ts.map +1 -0
- package/dist/self-tuning/index.js +25 -0
- package/dist/self-tuning/index.js.map +1 -0
- package/dist/self-tuning/judge.d.ts +66 -0
- package/dist/self-tuning/judge.d.ts.map +1 -0
- package/dist/self-tuning/judge.js +145 -0
- package/dist/self-tuning/judge.js.map +1 -0
- package/dist/self-tuning/llm-tuner.d.ts +83 -0
- package/dist/self-tuning/llm-tuner.d.ts.map +1 -0
- package/dist/self-tuning/llm-tuner.js +191 -0
- package/dist/self-tuning/llm-tuner.js.map +1 -0
- package/dist/self-tuning/orchestrator.d.ts +90 -0
- package/dist/self-tuning/orchestrator.d.ts.map +1 -0
- package/dist/self-tuning/orchestrator.js +126 -0
- package/dist/self-tuning/orchestrator.js.map +1 -0
- package/dist/self-tuning/paired-validator.d.ts +50 -0
- package/dist/self-tuning/paired-validator.d.ts.map +1 -0
- package/dist/self-tuning/paired-validator.js +78 -0
- package/dist/self-tuning/paired-validator.js.map +1 -0
- package/dist/self-tuning/profiles/index.d.ts +11 -0
- package/dist/self-tuning/profiles/index.d.ts.map +1 -0
- package/dist/self-tuning/profiles/index.js +15 -0
- package/dist/self-tuning/profiles/index.js.map +1 -0
- package/dist/self-tuning/profiles/opus48.d.ts +11 -0
- package/dist/self-tuning/profiles/opus48.d.ts.map +1 -0
- package/dist/self-tuning/profiles/opus48.js +27 -0
- package/dist/self-tuning/profiles/opus48.js.map +1 -0
- package/dist/self-tuning/profiles/qwen36.d.ts +11 -0
- package/dist/self-tuning/profiles/qwen36.d.ts.map +1 -0
- package/dist/self-tuning/profiles/qwen36.js +33 -0
- package/dist/self-tuning/profiles/qwen36.js.map +1 -0
- package/dist/self-tuning/quality-scorer.d.ts +90 -0
- package/dist/self-tuning/quality-scorer.d.ts.map +1 -0
- package/dist/self-tuning/quality-scorer.js +180 -0
- package/dist/self-tuning/quality-scorer.js.map +1 -0
- package/dist/self-tuning/realtime-adaptor.d.ts +78 -0
- package/dist/self-tuning/realtime-adaptor.d.ts.map +1 -0
- package/dist/self-tuning/realtime-adaptor.js +127 -0
- package/dist/self-tuning/realtime-adaptor.js.map +1 -0
- package/dist/self-tuning/run.d.ts +61 -0
- package/dist/self-tuning/run.d.ts.map +1 -0
- package/dist/self-tuning/run.js +122 -0
- package/dist/self-tuning/run.js.map +1 -0
- package/dist/self-tuning/search-reducer.d.ts +76 -0
- package/dist/self-tuning/search-reducer.d.ts.map +1 -0
- package/dist/self-tuning/search-reducer.js +349 -0
- package/dist/self-tuning/search-reducer.js.map +1 -0
- package/dist/self-tuning/tuning-profile.d.ts +72 -0
- package/dist/self-tuning/tuning-profile.d.ts.map +1 -0
- package/dist/self-tuning/tuning-profile.js +134 -0
- package/dist/self-tuning/tuning-profile.js.map +1 -0
- package/dist/telemetry/session-telemetry.d.ts +25 -0
- package/dist/telemetry/session-telemetry.d.ts.map +1 -1
- package/dist/telemetry/session-telemetry.js +32 -0
- package/dist/telemetry/session-telemetry.js.map +1 -1
- package/dist/types/config.d.ts +13 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -1
- package/docs/design/LLM_SELF_TUNING_ANALYSIS.md +617 -0
- package/docs/guides/SELF_TUNING.md +137 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.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 +355 -29
- package/tools/agents/scripts/confidence_escalation.py +60 -4
- package/tools/agents/tests/test_confidence_escalation.py +77 -0
|
@@ -180,6 +180,19 @@ PROXY_SLOT_HANG_TIMEOUT = float(os.environ.get("PROXY_SLOT_HANG_TIMEOUT", "120")
|
|
|
180
180
|
PROXY_UPSTREAM_RETRY_MAX = int(os.environ.get("PROXY_UPSTREAM_RETRY_MAX", "3"))
|
|
181
181
|
PROXY_UPSTREAM_RETRY_DELAY_SECS = float(os.environ.get("PROXY_UPSTREAM_RETRY_DELAY_SECS", "5"))
|
|
182
182
|
PROXY_MAX_CONNECTIONS = int(os.environ.get("PROXY_MAX_CONNECTIONS", "20"))
|
|
183
|
+
# CLOSE-WAIT reaper: abandoned upstream connections (a cancelled request
|
|
184
|
+
# whose httpx connection is never closed — llama's response sits unread in
|
|
185
|
+
# the socket, CLOSE-WAIT) accrue and saturate the pool. The reaper reads
|
|
186
|
+
# /proc/net/tcp, counts CLOSE-WAIT sockets to the upstream, and triggers a
|
|
187
|
+
# (safe, stream-preserving) pool self-heal when they exceed the threshold.
|
|
188
|
+
PROXY_CLOSEWAIT_REAP_THRESHOLD = int(os.environ.get("PROXY_CLOSEWAIT_REAP_THRESHOLD", "30"))
|
|
189
|
+
# Reaper is OPT-IN (default 0 = off): live 2026-07-12 under a SATURATED
|
|
190
|
+
# upstream (llama at 3/3 slots), its pool-swap mechanism turned a slow
|
|
191
|
+
# abandoned-connection leak into ReadError->500 bursts (74 500s/16min) —
|
|
192
|
+
# constant swaps churned in-flight connections. The graceful path
|
|
193
|
+
# (PoolTimeout -> 529 backoff + a large PROXY_MAX_CONNECTIONS) handles the
|
|
194
|
+
# leak without client-visible errors. Set >0 to re-enable the reaper.
|
|
195
|
+
PROXY_CLOSEWAIT_REAP_INTERVAL = float(os.environ.get("PROXY_CLOSEWAIT_REAP_INTERVAL", "0"))
|
|
183
196
|
PROXY_CONTEXT_WINDOW = int(os.environ.get("PROXY_CONTEXT_WINDOW", "0"))
|
|
184
197
|
PROXY_CONTEXT_PRUNE_THRESHOLD = float(
|
|
185
198
|
os.environ.get("PROXY_CONTEXT_PRUNE_THRESHOLD", "0.85")
|
|
@@ -2283,8 +2296,28 @@ def prune_conversation(
|
|
|
2283
2296
|
# Budget for messages
|
|
2284
2297
|
message_budget = target_tokens - overhead_tokens
|
|
2285
2298
|
if message_budget <= 0:
|
|
2286
|
-
|
|
2287
|
-
|
|
2299
|
+
# Tool-heavy clients (observed: 37 MCP tools whose schema estimate x1.5
|
|
2300
|
+
# safety factor exceeds the whole 40% critical-prune target) used to
|
|
2301
|
+
# ERROR-RETURN here with the body untouched — no pruning at all, so the
|
|
2302
|
+
# session pinned at 115%+, Option E clamped every output to 1024 tokens,
|
|
2303
|
+
# and all substantial writes truncated forever (24+ consecutive no-op
|
|
2304
|
+
# prunes + circuit breaker every turn). The schemas are client-fixed
|
|
2305
|
+
# and non-droppable; give MESSAGES a minimal floor and prune into it
|
|
2306
|
+
# best-effort instead of giving up.
|
|
2307
|
+
message_budget = max(1, int(context_window * 0.20))
|
|
2308
|
+
# The condition is PERMANENT for a tool-heavy client (fires per
|
|
2309
|
+
# request, observed every ~2s) — warn once per session, then debug.
|
|
2310
|
+
already = bool(monitor is not None and getattr(monitor, "floor_budget_logged", False))
|
|
2311
|
+
log = logger.debug if already else logger.warning
|
|
2312
|
+
if monitor is not None:
|
|
2313
|
+
monitor.floor_budget_logged = True
|
|
2314
|
+
log(
|
|
2315
|
+
"System+tools overhead (~%d est tokens) consumes the %.0f%% prune "
|
|
2316
|
+
"target — pruning messages into a floor budget of %d tokens instead",
|
|
2317
|
+
overhead_tokens,
|
|
2318
|
+
target_fraction * 100,
|
|
2319
|
+
message_budget,
|
|
2320
|
+
)
|
|
2288
2321
|
|
|
2289
2322
|
# Always keep the first user message and the last N messages
|
|
2290
2323
|
KEEP_LAST = keep_last
|
|
@@ -2386,6 +2419,180 @@ def prune_conversation(
|
|
|
2386
2419
|
# Module-level httpx.AsyncClient for connection reuse + keep-alive.
|
|
2387
2420
|
# Granular timeouts: short connect, long read for streaming LLM output.
|
|
2388
2421
|
http_client: httpx.AsyncClient | None = None
|
|
2422
|
+
_last_pool_reset_ts: float = 0.0
|
|
2423
|
+
|
|
2424
|
+
# Transient upstream failures that are safe to retry (a fresh POST re-runs
|
|
2425
|
+
# the request). Single source of truth — used by BOTH the buffered
|
|
2426
|
+
# _post_with_retry_inner AND the streaming send loop, which previously had
|
|
2427
|
+
# divergent copies (the streaming copy lacked ReadError, so a mid-setup
|
|
2428
|
+
# connection reset on a streamed turn 500'd; live 2026-07-11).
|
|
2429
|
+
_UPSTREAM_RETRY_EXCEPTIONS = (
|
|
2430
|
+
httpx.ConnectError,
|
|
2431
|
+
httpx.ConnectTimeout,
|
|
2432
|
+
httpx.RemoteProtocolError,
|
|
2433
|
+
httpx.ReadTimeout,
|
|
2434
|
+
httpx.ReadError,
|
|
2435
|
+
httpx.WriteError,
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
def _upstream_port() -> int:
|
|
2440
|
+
"""The upstream (llama) TCP port, for CLOSE-WAIT accounting."""
|
|
2441
|
+
try:
|
|
2442
|
+
return int(LLAMA_CPP_BASE.rsplit(":", 1)[1].split("/")[0])
|
|
2443
|
+
except Exception:
|
|
2444
|
+
return 8080
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
def _count_upstream_close_wait() -> int:
|
|
2448
|
+
"""Count CLOSE-WAIT sockets to the upstream port via /proc/net/tcp.
|
|
2449
|
+
|
|
2450
|
+
Cheap (one file read), no subprocess. TCP state 08 == CLOSE_WAIT; the
|
|
2451
|
+
remote port is hex in the 3rd column's ':PORT' suffix.
|
|
2452
|
+
"""
|
|
2453
|
+
port_hex = f"{_upstream_port():04X}"
|
|
2454
|
+
n = 0
|
|
2455
|
+
try:
|
|
2456
|
+
with open("/proc/net/tcp", "r") as fh:
|
|
2457
|
+
next(fh, None)
|
|
2458
|
+
for line in fh:
|
|
2459
|
+
parts = line.split()
|
|
2460
|
+
if len(parts) < 4:
|
|
2461
|
+
continue
|
|
2462
|
+
if parts[3] == "08" and parts[2].endswith(":" + port_hex):
|
|
2463
|
+
n += 1
|
|
2464
|
+
except Exception:
|
|
2465
|
+
return 0
|
|
2466
|
+
return n
|
|
2467
|
+
|
|
2468
|
+
|
|
2469
|
+
async def _closewait_reaper() -> None:
|
|
2470
|
+
"""Periodic: reap abandoned upstream CLOSE-WAIT connections.
|
|
2471
|
+
|
|
2472
|
+
When they exceed the threshold, replace the pool (safe self-heal) — the
|
|
2473
|
+
retired pool's aclose (once its legit in-flight drains) force-closes the
|
|
2474
|
+
abandoned connections, which have no in-flight coroutine of their own.
|
|
2475
|
+
"""
|
|
2476
|
+
if PROXY_CLOSEWAIT_REAP_INTERVAL <= 0:
|
|
2477
|
+
return
|
|
2478
|
+
while True:
|
|
2479
|
+
try:
|
|
2480
|
+
await asyncio.sleep(PROXY_CLOSEWAIT_REAP_INTERVAL)
|
|
2481
|
+
cw = _count_upstream_close_wait()
|
|
2482
|
+
if cw >= PROXY_CLOSEWAIT_REAP_THRESHOLD:
|
|
2483
|
+
_maybe_reset_http_client(f"CLOSE-WAIT reaper ({cw} abandoned upstream connections)")
|
|
2484
|
+
except asyncio.CancelledError:
|
|
2485
|
+
raise
|
|
2486
|
+
except Exception as exc:
|
|
2487
|
+
logger.warning("CLOSE-WAIT reaper error: %s", exc)
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
def _inflight_inc(client) -> None:
|
|
2491
|
+
try:
|
|
2492
|
+
client._uap_inflight += 1
|
|
2493
|
+
except Exception:
|
|
2494
|
+
pass
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def _inflight_dec(client) -> None:
|
|
2498
|
+
try:
|
|
2499
|
+
client._uap_inflight -= 1
|
|
2500
|
+
except Exception:
|
|
2501
|
+
pass
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
def _detach_aclose(closeable) -> None:
|
|
2505
|
+
"""Close an upstream stream/response on a DETACHED task so it completes
|
|
2506
|
+
even when the requesting ASGI task is being cancelled (client disconnect).
|
|
2507
|
+
An in-line ``await x.aclose()`` in a streaming finally can itself be
|
|
2508
|
+
cancelled before it closes the socket, leaking the upstream connection —
|
|
2509
|
+
it accrues as CLOSE-WAIT and eventually saturates the pool (live
|
|
2510
|
+
2026-07-11: CW climbed 26→52 with zero client errors after the cascade was
|
|
2511
|
+
fixed). ensure_future schedules the close independently of the dying task.
|
|
2512
|
+
"""
|
|
2513
|
+
async def _run() -> None:
|
|
2514
|
+
try:
|
|
2515
|
+
await closeable.aclose()
|
|
2516
|
+
except Exception:
|
|
2517
|
+
pass
|
|
2518
|
+
try:
|
|
2519
|
+
asyncio.ensure_future(_run())
|
|
2520
|
+
except RuntimeError:
|
|
2521
|
+
pass # no running loop — nothing to detach onto
|
|
2522
|
+
|
|
2523
|
+
|
|
2524
|
+
def _build_http_client() -> httpx.AsyncClient:
|
|
2525
|
+
"""Upstream client factory — used at startup AND by pool self-healing."""
|
|
2526
|
+
c = httpx.AsyncClient(
|
|
2527
|
+
timeout=httpx.Timeout(
|
|
2528
|
+
connect=10.0, # 10s to establish connection
|
|
2529
|
+
read=PROXY_READ_TIMEOUT, # configurable (default 10 min)
|
|
2530
|
+
write=30.0, # 30s to send the request body
|
|
2531
|
+
pool=10.0, # 10s to acquire a pool connection
|
|
2532
|
+
),
|
|
2533
|
+
limits=httpx.Limits(
|
|
2534
|
+
max_connections=PROXY_MAX_CONNECTIONS,
|
|
2535
|
+
# No upstream keepalive: llama.cpp closes idle keepalive
|
|
2536
|
+
# connections faster than a 120s expiry, so they piled as
|
|
2537
|
+
# CLOSE-WAIT and exhausted the pool (live: 51 zombies, PoolTimeout
|
|
2538
|
+
# 500-storm). The upstream is localhost at low concurrency —
|
|
2539
|
+
# connection reuse saves microseconds and is not worth the leak
|
|
2540
|
+
# class. Each request opens and cleanly closes its own connection.
|
|
2541
|
+
max_keepalive_connections=0,
|
|
2542
|
+
keepalive_expiry=0,
|
|
2543
|
+
),
|
|
2544
|
+
)
|
|
2545
|
+
# Per-CLIENT in-flight counter: a retired pool must drain on ITS OWN
|
|
2546
|
+
# requests (a global counter can't isolate it — new-pool traffic keeps a
|
|
2547
|
+
# global high, so the retire waited the full backstop and never reaped;
|
|
2548
|
+
# live 2026-07-11: reaper fired 7x, CLOSE-WAIT still climbed 30→50).
|
|
2549
|
+
c._uap_inflight = 0 # type: ignore[attr-defined]
|
|
2550
|
+
return c
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
def _maybe_reset_http_client(reason: str) -> bool:
|
|
2554
|
+
"""Pool self-healing: swap in a fresh AsyncClient, retiring the old one.
|
|
2555
|
+
|
|
2556
|
+
Rare cancellation races (asyncio.wait_for cutting an httpx send mid-
|
|
2557
|
+
response — the guarded non-stream path every tool turn uses) leak
|
|
2558
|
+
connections the pool counts as busy forever; observed live as 12
|
|
2559
|
+
CLOSE-WAIT zombies out of a 20-connection pool and a 205-request
|
|
2560
|
+
PoolTimeout 500-storm. There is no public API to reap them, so on
|
|
2561
|
+
saturation we REPLACE the client: new requests get a fresh pool
|
|
2562
|
+
immediately (handlers snapshot the global per request), in-flight
|
|
2563
|
+
requests finish on the old object, which is closed after a grace period.
|
|
2564
|
+
Cooldown-guarded so a burst cannot thrash clients.
|
|
2565
|
+
"""
|
|
2566
|
+
global http_client, _last_pool_reset_ts
|
|
2567
|
+
now = time.time()
|
|
2568
|
+
if now - _last_pool_reset_ts < 60:
|
|
2569
|
+
return False
|
|
2570
|
+
if http_client is None:
|
|
2571
|
+
return False
|
|
2572
|
+
_last_pool_reset_ts = now
|
|
2573
|
+
old_client = http_client
|
|
2574
|
+
http_client = _build_http_client()
|
|
2575
|
+
logger.error(
|
|
2576
|
+
"POOL SELF-HEAL: replaced upstream connection pool (%s); old pool closes after grace period",
|
|
2577
|
+
reason,
|
|
2578
|
+
)
|
|
2579
|
+
|
|
2580
|
+
async def _retire() -> None:
|
|
2581
|
+
# Close the old pool once ITS OWN in-flight requests (per-client
|
|
2582
|
+
# counter — streaming + buffered) drain to zero: no live request
|
|
2583
|
+
# remains on the old client, so aclose() force-closes only its
|
|
2584
|
+
# ABANDONED CLOSE-WAIT connections and never a live stream. Capped at
|
|
2585
|
+
# GENERATION_TIMEOUT as a backstop.
|
|
2586
|
+
deadline = time.time() + max(60.0, PROXY_GENERATION_TIMEOUT)
|
|
2587
|
+
while time.time() < deadline and getattr(old_client, "_uap_inflight", 0) > 0:
|
|
2588
|
+
await asyncio.sleep(1.0)
|
|
2589
|
+
try:
|
|
2590
|
+
await old_client.aclose()
|
|
2591
|
+
except Exception:
|
|
2592
|
+
pass
|
|
2593
|
+
|
|
2594
|
+
asyncio.ensure_future(_retire())
|
|
2595
|
+
return True
|
|
2389
2596
|
|
|
2390
2597
|
# ---------------------------------------------------------------------------
|
|
2391
2598
|
# Concurrency Control
|
|
@@ -2870,7 +3077,11 @@ async def _post_with_retry_inner(
|
|
|
2870
3077
|
last_exc: Exception | None = None
|
|
2871
3078
|
for attempt in range(PROXY_UPSTREAM_RETRY_MAX):
|
|
2872
3079
|
try:
|
|
2873
|
-
|
|
3080
|
+
_inflight_inc(client)
|
|
3081
|
+
try:
|
|
3082
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
3083
|
+
finally:
|
|
3084
|
+
_inflight_dec(client)
|
|
2874
3085
|
# Cycle 19 Option 1: if 503 "Loading model", wait for health then retry
|
|
2875
3086
|
if _is_loading_model_503(resp):
|
|
2876
3087
|
logger.warning(
|
|
@@ -2883,11 +3094,11 @@ async def _post_with_retry_inner(
|
|
|
2883
3094
|
continue # retry the request now that upstream is healthy
|
|
2884
3095
|
return resp # return the 503 if health wait timed out
|
|
2885
3096
|
return resp
|
|
2886
|
-
except
|
|
3097
|
+
except _UPSTREAM_RETRY_EXCEPTIONS as exc:
|
|
2887
3098
|
last_exc = exc
|
|
2888
3099
|
if attempt < PROXY_UPSTREAM_RETRY_MAX - 1:
|
|
2889
3100
|
logger.warning(
|
|
2890
|
-
"Upstream
|
|
3101
|
+
"Upstream transient error (attempt %d/%d): %s – retrying in %.0fs",
|
|
2891
3102
|
attempt + 1,
|
|
2892
3103
|
PROXY_UPSTREAM_RETRY_MAX,
|
|
2893
3104
|
type(exc).__name__,
|
|
@@ -2996,19 +3207,7 @@ async def lifespan(app: FastAPI):
|
|
|
2996
3207
|
PROXY_CONCURRENCY_QUEUE_TIMEOUT,
|
|
2997
3208
|
)
|
|
2998
3209
|
_prepare_slot_save_dir()
|
|
2999
|
-
http_client =
|
|
3000
|
-
timeout=httpx.Timeout(
|
|
3001
|
-
connect=10.0, # 10s to establish connection
|
|
3002
|
-
read=PROXY_READ_TIMEOUT, # configurable (default 10 min)
|
|
3003
|
-
write=30.0, # 30s to send the request body
|
|
3004
|
-
pool=10.0, # 10s to acquire a pool connection
|
|
3005
|
-
),
|
|
3006
|
-
limits=httpx.Limits(
|
|
3007
|
-
max_connections=PROXY_MAX_CONNECTIONS,
|
|
3008
|
-
max_keepalive_connections=PROXY_MAX_CONNECTIONS // 2,
|
|
3009
|
-
keepalive_expiry=120,
|
|
3010
|
-
),
|
|
3011
|
-
)
|
|
3210
|
+
http_client = _build_http_client()
|
|
3012
3211
|
logger.info(
|
|
3013
3212
|
"Proxy started: listening on %s:%d -> upstream %s",
|
|
3014
3213
|
PROXY_HOST,
|
|
@@ -3016,6 +3215,8 @@ async def lifespan(app: FastAPI):
|
|
|
3016
3215
|
LLAMA_CPP_BASE,
|
|
3017
3216
|
)
|
|
3018
3217
|
|
|
3218
|
+
_reaper_task = asyncio.ensure_future(_closewait_reaper())
|
|
3219
|
+
|
|
3019
3220
|
# Auto-detect context window from upstream server
|
|
3020
3221
|
default_context_window = await detect_context_window(http_client)
|
|
3021
3222
|
for mon in session_monitors.values():
|
|
@@ -3082,6 +3283,7 @@ async def lifespan(app: FastAPI):
|
|
|
3082
3283
|
)
|
|
3083
3284
|
|
|
3084
3285
|
yield
|
|
3286
|
+
_reaper_task.cancel()
|
|
3085
3287
|
await http_client.aclose()
|
|
3086
3288
|
http_client = None
|
|
3087
3289
|
if upstream_semaphore is not None:
|
|
@@ -3098,6 +3300,37 @@ app = FastAPI(
|
|
|
3098
3300
|
lifespan=lifespan,
|
|
3099
3301
|
)
|
|
3100
3302
|
|
|
3303
|
+
@app.exception_handler(httpx.PoolTimeout)
|
|
3304
|
+
async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
|
|
3305
|
+
"""Saturated upstream pool: answer 529 overloaded (Anthropic semantics —
|
|
3306
|
+
clients back off with jitter instead of fast-retrying a 500 traceback,
|
|
3307
|
+
which amplified the storm).
|
|
3308
|
+
|
|
3309
|
+
NOTE: this deliberately does NOT swap the pool. Under a saturated upstream
|
|
3310
|
+
(llama at 3/3 slots) the pool-swap self-heal churned in-flight connections
|
|
3311
|
+
into ReadError→500 bursts (live 2026-07-12) — the same mechanism the
|
|
3312
|
+
reaper was disabled for. Pure backpressure (529 + a large
|
|
3313
|
+
PROXY_MAX_CONNECTIONS) is the graceful posture; leaked connections are
|
|
3314
|
+
cleared by the periodic proxy restart, not by mid-load pool churn. Set
|
|
3315
|
+
PROXY_POOL_SWAP_ON_SATURATION=1 to restore the swap."""
|
|
3316
|
+
if os.environ.get("PROXY_POOL_SWAP_ON_SATURATION") == "1":
|
|
3317
|
+
_maybe_reset_http_client("PoolTimeout on " + request.url.path)
|
|
3318
|
+
return Response(
|
|
3319
|
+
content=json.dumps(
|
|
3320
|
+
{
|
|
3321
|
+
"type": "error",
|
|
3322
|
+
"error": {
|
|
3323
|
+
"type": "overloaded_error",
|
|
3324
|
+
"message": "Upstream connection pool saturated — retry shortly.",
|
|
3325
|
+
},
|
|
3326
|
+
}
|
|
3327
|
+
),
|
|
3328
|
+
status_code=529,
|
|
3329
|
+
media_type="application/json",
|
|
3330
|
+
headers={"retry-after": "10"},
|
|
3331
|
+
)
|
|
3332
|
+
|
|
3333
|
+
|
|
3101
3334
|
|
|
3102
3335
|
# Open paths that never require the shared secret (liveness / discovery), so a
|
|
3103
3336
|
# LAN health check or an SDK model-list probe works without the token.
|
|
@@ -5881,6 +6114,52 @@ def _openai_has_tool_calls(openai_resp: dict) -> bool:
|
|
|
5881
6114
|
return bool(_extract_openai_tool_calls(openai_resp))
|
|
5882
6115
|
|
|
5883
6116
|
|
|
6117
|
+
def _json_close_suffix(fragment: str) -> str:
|
|
6118
|
+
"""Minimal suffix that closes a truncated JSON fragment so it parses.
|
|
6119
|
+
|
|
6120
|
+
Streamed tool-call arguments cut off by the token limit (finish_reason=
|
|
6121
|
+
length) used to reach the client as unterminated JSON — Claude Code then
|
|
6122
|
+
throws InputValidationError ("could not be parsed as JSON ... first 200 of
|
|
6123
|
+
N bytes") and retries into the same wall. Scanning with the same
|
|
6124
|
+
in-string/escape/nesting rules a JSON parser uses, this returns the
|
|
6125
|
+
characters needed to terminate the fragment: a visible truncation marker +
|
|
6126
|
+
closing quote when cut mid-string, then closers for every open object /
|
|
6127
|
+
array. Returns '' when the fragment already parses (or is empty).
|
|
6128
|
+
"""
|
|
6129
|
+
if not fragment:
|
|
6130
|
+
return ""
|
|
6131
|
+
try:
|
|
6132
|
+
json.loads(fragment)
|
|
6133
|
+
return ""
|
|
6134
|
+
except Exception:
|
|
6135
|
+
pass
|
|
6136
|
+
stack: list[str] = []
|
|
6137
|
+
in_string = False
|
|
6138
|
+
escaped = False
|
|
6139
|
+
for ch in fragment:
|
|
6140
|
+
if in_string:
|
|
6141
|
+
if escaped:
|
|
6142
|
+
escaped = False
|
|
6143
|
+
elif ch == "\\":
|
|
6144
|
+
escaped = True
|
|
6145
|
+
elif ch == '"':
|
|
6146
|
+
in_string = False
|
|
6147
|
+
continue
|
|
6148
|
+
if ch == '"':
|
|
6149
|
+
in_string = True
|
|
6150
|
+
elif ch in "{[":
|
|
6151
|
+
stack.append("}" if ch == "{" else "]")
|
|
6152
|
+
elif ch in "}]" and stack:
|
|
6153
|
+
stack.pop()
|
|
6154
|
+
suffix = ""
|
|
6155
|
+
if in_string:
|
|
6156
|
+
if escaped:
|
|
6157
|
+
suffix += "\\" # complete the dangling escape as a literal backslash
|
|
6158
|
+
suffix += "\u2026[TRUNCATED BY TOKEN LIMIT]\""
|
|
6159
|
+
suffix += "".join(reversed(stack))
|
|
6160
|
+
return suffix
|
|
6161
|
+
|
|
6162
|
+
|
|
5884
6163
|
def _parse_openai_function_arguments(raw_args) -> tuple[dict | None, str | None]:
|
|
5885
6164
|
if isinstance(raw_args, dict):
|
|
5886
6165
|
return raw_args, None
|
|
@@ -9110,8 +9389,17 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
|
|
|
9110
9389
|
except asyncio.TimeoutError:
|
|
9111
9390
|
yield 'event: ping\ndata: {"type": "ping"}\n\n'
|
|
9112
9391
|
except asyncio.CancelledError:
|
|
9113
|
-
# Client disconnected — cancel the in-flight produce
|
|
9392
|
+
# Client disconnected — cancel the in-flight produce AND await its
|
|
9393
|
+
# cleanup so httpx returns/closes the upstream connection before we
|
|
9394
|
+
# unwind. Without the await, the cancel is merely requested and the
|
|
9395
|
+
# socket strands in CLOSE-WAIT (the connection-leak → PoolTimeout 500
|
|
9396
|
+
# storm root cause). Shielded + time-boxed so a stuck close can't hang
|
|
9397
|
+
# the disconnect path.
|
|
9114
9398
|
task.cancel()
|
|
9399
|
+
try:
|
|
9400
|
+
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
|
9401
|
+
except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
|
|
9402
|
+
pass
|
|
9115
9403
|
raise
|
|
9116
9404
|
except Exception as exc:
|
|
9117
9405
|
logger.error("heartbeat produce failed: %s", exc)
|
|
@@ -9232,6 +9520,8 @@ async def stream_anthropic_response(
|
|
|
9232
9520
|
- Graceful error recovery on upstream connection drops
|
|
9233
9521
|
- Proper upstream response closure on client disconnect
|
|
9234
9522
|
"""
|
|
9523
|
+
_sr_client = getattr(openai_stream, "_uap_client", None)
|
|
9524
|
+
_inflight_inc(_sr_client) if _sr_client is not None else None
|
|
9235
9525
|
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
|
|
9236
9526
|
stream_started = time.monotonic()
|
|
9237
9527
|
|
|
@@ -9375,14 +9665,36 @@ async def stream_anthropic_response(
|
|
|
9375
9665
|
logger.error("Unexpected stream error: %s: %s", type(exc).__name__, exc)
|
|
9376
9666
|
finish_reason = "end_turn"
|
|
9377
9667
|
finally:
|
|
9378
|
-
|
|
9379
|
-
|
|
9668
|
+
if _sr_client is not None:
|
|
9669
|
+
_inflight_dec(_sr_client)
|
|
9670
|
+
# Detached close: a bare 'await aclose()' here is itself cancellable
|
|
9671
|
+
# when the client disconnected (the common case), leaving the upstream
|
|
9672
|
+
# connection un-closed → CLOSE-WAIT leak. Detaching guarantees it runs.
|
|
9673
|
+
_detach_aclose(openai_stream)
|
|
9380
9674
|
|
|
9381
9675
|
# Close any open tool call blocks (skip if XML recovery already emitted them)
|
|
9382
9676
|
xml_recovered = tool_calls_by_index.pop("_xml_recovered", False)
|
|
9383
9677
|
if tool_calls_by_index and not xml_recovered:
|
|
9384
9678
|
for tc in tool_calls_by_index.values():
|
|
9385
9679
|
if isinstance(tc, dict) and "block_index" in tc:
|
|
9680
|
+
# Truncation repair: if the accumulated arguments do not parse
|
|
9681
|
+
# (token-limit cut mid-JSON), stream one final fragment that
|
|
9682
|
+
# closes the JSON validly — with a visible marker when cut
|
|
9683
|
+
# inside a string — so the client can always parse the block.
|
|
9684
|
+
# stop_reason still reports max_tokens; the marker makes the
|
|
9685
|
+
# truncation visible in whatever the tool writes.
|
|
9686
|
+
repair = _json_close_suffix(tc.get("arguments", ""))
|
|
9687
|
+
if repair:
|
|
9688
|
+
tc["arguments"] = tc.get("arguments", "") + repair
|
|
9689
|
+
logger.warning(
|
|
9690
|
+
"STREAM TOOL-CALL REPAIR: closed truncated '%s' arguments (+%d chars) — client would otherwise fail to parse",
|
|
9691
|
+
tc.get("name", "?"),
|
|
9692
|
+
len(repair),
|
|
9693
|
+
)
|
|
9694
|
+
yield (
|
|
9695
|
+
f"event: content_block_delta\n"
|
|
9696
|
+
f"data: {json.dumps({'type': 'content_block_delta', 'index': tc['block_index'], 'delta': {'type': 'input_json_delta', 'partial_json': repair}})}\n\n"
|
|
9697
|
+
)
|
|
9386
9698
|
yield (
|
|
9387
9699
|
f"event: content_block_stop\n"
|
|
9388
9700
|
f"data: {json.dumps({'type': 'content_block_stop', 'index': tc['block_index']})}\n\n"
|
|
@@ -9633,9 +9945,16 @@ def _build_passthrough_headers(request: Request) -> dict | None:
|
|
|
9633
9945
|
|
|
9634
9946
|
|
|
9635
9947
|
async def _stream_passthrough(resp: httpx.Response):
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9948
|
+
_pt_client = getattr(resp, "_uap_client", None)
|
|
9949
|
+
if _pt_client is not None:
|
|
9950
|
+
_inflight_inc(_pt_client)
|
|
9951
|
+
try:
|
|
9952
|
+
async for chunk in resp.aiter_bytes():
|
|
9953
|
+
yield chunk
|
|
9954
|
+
finally:
|
|
9955
|
+
if _pt_client is not None:
|
|
9956
|
+
_inflight_dec(_pt_client)
|
|
9957
|
+
_detach_aclose(resp)
|
|
9639
9958
|
|
|
9640
9959
|
|
|
9641
9960
|
async def _passthrough_anthropic_request(
|
|
@@ -9676,6 +9995,7 @@ async def _passthrough_anthropic_request(
|
|
|
9676
9995
|
resp = await client.send(
|
|
9677
9996
|
client.build_request("POST", url, json=body, headers=headers, timeout=pt_timeout)
|
|
9678
9997
|
)
|
|
9998
|
+
setattr(resp, "_uap_client", client)
|
|
9679
9999
|
if resp.status_code != 200:
|
|
9680
10000
|
return Response(
|
|
9681
10001
|
content=resp.text,
|
|
@@ -10235,12 +10555,14 @@ async def messages(request: Request):
|
|
|
10235
10555
|
# Without tools (finalize): retry with capped max_tokens for clean text.
|
|
10236
10556
|
has_tools = bool(strict_body.get("tools"))
|
|
10237
10557
|
retry_body = dict(strict_body)
|
|
10238
|
-
|
|
10558
|
+
# Tool retries need room for Qwen's mandatory thinking PLUS
|
|
10559
|
+
# complete tool-call arguments — 2048 produced truncated JSON.
|
|
10560
|
+
retry_body["max_tokens"] = 8192 if has_tools else 2048
|
|
10239
10561
|
retry_body["temperature"] = 0.1
|
|
10240
10562
|
retry_body["stream"] = False
|
|
10241
10563
|
if has_tools:
|
|
10242
10564
|
retry_body["tool_choice"] = "required"
|
|
10243
|
-
logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=
|
|
10565
|
+
logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=8192")
|
|
10244
10566
|
else:
|
|
10245
10567
|
logger.warning("DEGENERATE RETRY: retrying text-only with max_tokens=2048 temp=0.1")
|
|
10246
10568
|
try:
|
|
@@ -10353,10 +10675,11 @@ async def messages(request: Request):
|
|
|
10353
10675
|
),
|
|
10354
10676
|
stream=True,
|
|
10355
10677
|
)
|
|
10678
|
+
setattr(resp, "_uap_client", client)
|
|
10356
10679
|
# Connection succeeded – break out of retry loop
|
|
10357
10680
|
last_exc = None
|
|
10358
10681
|
break
|
|
10359
|
-
except
|
|
10682
|
+
except _UPSTREAM_RETRY_EXCEPTIONS as exc:
|
|
10360
10683
|
last_exc = exc
|
|
10361
10684
|
if attempt < MAX_UPSTREAM_RETRIES - 1:
|
|
10362
10685
|
logger.warning(
|
|
@@ -10427,6 +10750,7 @@ async def messages(request: Request):
|
|
|
10427
10750
|
),
|
|
10428
10751
|
stream=True,
|
|
10429
10752
|
)
|
|
10753
|
+
setattr(resp, "_uap_client", client)
|
|
10430
10754
|
if resp.status_code == 200:
|
|
10431
10755
|
return StreamingResponse(
|
|
10432
10756
|
stream_anthropic_response(resp, model, monitor, body),
|
|
@@ -10451,6 +10775,7 @@ async def messages(request: Request):
|
|
|
10451
10775
|
),
|
|
10452
10776
|
stream=True,
|
|
10453
10777
|
)
|
|
10778
|
+
setattr(resp, "_uap_client", client)
|
|
10454
10779
|
if resp.status_code == 200:
|
|
10455
10780
|
return StreamingResponse(
|
|
10456
10781
|
stream_anthropic_response(resp, model, monitor, body),
|
|
@@ -10689,10 +11014,11 @@ async def messages(request: Request):
|
|
|
10689
11014
|
openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
|
|
10690
11015
|
# Degenerate retry for non-guarded stream path
|
|
10691
11016
|
if was_degenerate and openai_body.get("tools"):
|
|
10692
|
-
logger.warning("DEGENERATE RETRY (stream): retrying with tool_choice=required max_tokens=
|
|
11017
|
+
logger.warning("DEGENERATE RETRY (stream): retrying with tool_choice=required max_tokens=8192")
|
|
10693
11018
|
retry_body = dict(openai_body)
|
|
10694
11019
|
retry_body["tool_choice"] = "required"
|
|
10695
|
-
|
|
11020
|
+
# See non-stream site: 2048 starves thinking + tool args.
|
|
11021
|
+
retry_body["max_tokens"] = 8192
|
|
10696
11022
|
retry_body["temperature"] = 0.1
|
|
10697
11023
|
retry_body["stream"] = False
|
|
10698
11024
|
try:
|
|
@@ -239,6 +239,54 @@ def load_reactor_signal(prompt_text, signal_dir=None, ttl=180.0, now=None):
|
|
|
239
239
|
return None
|
|
240
240
|
|
|
241
241
|
|
|
242
|
+
# ---- cross-process: consume the LLM-Self-Tuning real-time adaptor (P4) --------
|
|
243
|
+
# The offline tuner finds a good STATIC config; the real-time adaptor writes a
|
|
244
|
+
# per-session adjustment (escalate / recipe / recon threshold / force-synthesis)
|
|
245
|
+
# from LIVE signals it observes out-of-band (tool-failure rate, per-turn quality,
|
|
246
|
+
# context pressure). The proxy freezes PROXY_* at startup and has no reload
|
|
247
|
+
# endpoint, so — exactly like the recipe signal — this is a file-signal read per
|
|
248
|
+
# request. OPT-IN via PROXY_REALTIME_ADAPT so default behavior is unchanged.
|
|
249
|
+
def _adaptation_signal_dir() -> str:
|
|
250
|
+
return os.environ.get("UAP_ADAPTATION_SIGNAL_DIR") or os.path.join(
|
|
251
|
+
os.path.expanduser("~"), ".cache", "uap", "adaptation-signals"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def realtime_adapt_enabled() -> bool:
|
|
256
|
+
# Auto-on: honor a fresh adaptation signal by default. Opt out with
|
|
257
|
+
# PROXY_REALTIME_ADAPT=0/false/off. (The emitter is the real master switch —
|
|
258
|
+
# with it off, no signal exists to honor.)
|
|
259
|
+
return str(os.environ.get("PROXY_REALTIME_ADAPT", "")).strip().lower() not in (
|
|
260
|
+
"0",
|
|
261
|
+
"false",
|
|
262
|
+
"off",
|
|
263
|
+
"no",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_adaptation_signal(session_id=None, signal_dir=None, ttl=180.0, now=None):
|
|
268
|
+
"""Return the fresh real-time adaptation signal dict, or None. Per-session
|
|
269
|
+
file first when session_id is given, then a rolling latest.json (single-
|
|
270
|
+
session common case). Fresh within ttl seconds. Never raises."""
|
|
271
|
+
d = signal_dir or _adaptation_signal_dir()
|
|
272
|
+
now = time.time() if now is None else now
|
|
273
|
+
candidates = []
|
|
274
|
+
if session_id:
|
|
275
|
+
safe = "".join(c if (c.isalnum() or c in "_-") else "_" for c in str(session_id)) or "session"
|
|
276
|
+
candidates.append(os.path.join(d, safe + ".json"))
|
|
277
|
+
candidates.append(os.path.join(d, "latest.json"))
|
|
278
|
+
for path in candidates:
|
|
279
|
+
try:
|
|
280
|
+
if os.path.exists(path):
|
|
281
|
+
with open(path) as f:
|
|
282
|
+
sig = json.load(f)
|
|
283
|
+
if now - float(sig.get("ts", 0)) <= ttl:
|
|
284
|
+
return sig
|
|
285
|
+
except Exception:
|
|
286
|
+
continue
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
242
290
|
def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) -> str:
|
|
243
291
|
if not settings.enabled or has_tools:
|
|
244
292
|
return "single"
|
|
@@ -261,10 +309,18 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
|
|
|
261
309
|
complexity = query_complexity(text)
|
|
262
310
|
shape = task_shape(text)
|
|
263
311
|
if settings.judge_available(pm) and (complexity == "complex" or shape == "reasoning"):
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
312
|
+
chosen = "fusion"
|
|
313
|
+
elif settings.judge_available(pm) and len(text) >= settings.auto_fusion_chars:
|
|
314
|
+
chosen = "fusion"
|
|
315
|
+
else:
|
|
316
|
+
chosen = "confidence"
|
|
317
|
+
# Real-time adaptation (opt-in): a fresh per-session signal may escalate this
|
|
318
|
+
# turn to the judge (e.g. tool-failure spike or a quality dip observed live).
|
|
319
|
+
if realtime_adapt_enabled() and settings.judge_available(pm):
|
|
320
|
+
asig = load_adaptation_signal()
|
|
321
|
+
if asig and (asig.get("escalate") or asig.get("recipe") == "fusion"):
|
|
322
|
+
chosen = "fusion"
|
|
323
|
+
return chosen
|
|
268
324
|
|
|
269
325
|
|
|
270
326
|
# ---- #1/#5 confidence signal ----------------------------------------------
|