@miller-tech/uap 1.46.3 → 1.46.7

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.
@@ -116,6 +116,14 @@ PROXY_HOST = os.environ.get("PROXY_HOST", "0.0.0.0")
116
116
  PROXY_LOG_LEVEL = os.environ.get("PROXY_LOG_LEVEL", "INFO").upper()
117
117
  PROXY_READ_TIMEOUT = float(os.environ.get("PROXY_READ_TIMEOUT", "180"))
118
118
  PROXY_GENERATION_TIMEOUT = float(os.environ.get("PROXY_GENERATION_TIMEOUT", "300"))
119
+ # Bound Anthropic-passthrough upstream calls. Without this they inherit the
120
+ # long streaming read timeout (default 1800s) and — because /v1/chat/completions
121
+ # forces a single non-streaming upstream call (for guardrail simplicity) — a
122
+ # slow or stuck Anthropic generation holds the request (and the single llama
123
+ # slot) for up to that long, which produced ~77-min benchmark hangs. 600s is
124
+ # generous for a long legitimate generation but converts a true hang into a
125
+ # fast, recoverable error.
126
+ PROXY_PASSTHROUGH_TIMEOUT = float(os.environ.get("PROXY_PASSTHROUGH_TIMEOUT", "600"))
119
127
  PROXY_SLOT_HANG_TIMEOUT = float(os.environ.get("PROXY_SLOT_HANG_TIMEOUT", "120"))
120
128
  PROXY_UPSTREAM_RETRY_MAX = int(os.environ.get("PROXY_UPSTREAM_RETRY_MAX", "3"))
121
129
  PROXY_UPSTREAM_RETRY_DELAY_SECS = float(os.environ.get("PROXY_UPSTREAM_RETRY_DELAY_SECS", "5"))
@@ -142,6 +150,15 @@ PROXY_LOOP_REPEAT_THRESHOLD = int(os.environ.get("PROXY_LOOP_REPEAT_THRESHOLD",
142
150
  PROXY_CYCLE_TRIGGER_REPEAT = int(os.environ.get("PROXY_CYCLE_TRIGGER_REPEAT", "3"))
143
151
  PROXY_FORCED_THRESHOLD = int(os.environ.get("PROXY_FORCED_THRESHOLD", "15"))
144
152
  PROXY_NO_PROGRESS_THRESHOLD = int(os.environ.get("PROXY_NO_PROGRESS_THRESHOLD", "3"))
153
+ # Fix D: streak-independent escape hatch. `no_progress_streak` resets to 0 on
154
+ # every turn whose last user message carries a tool_result (line ~3835) — i.e.
155
+ # every turn of a normal agentic loop — so the no_progress-gated LOOP BREAKER
156
+ # patterns can never accumulate. After this many *consecutive* forced-'required'
157
+ # turns (which DOES accumulate across an agentic loop via consecutive_forced_count),
158
+ # release tool_choice to 'auto' regardless of no_progress_streak so the model can
159
+ # emit a terminating response. Set well above any healthy run length (healthy
160
+ # loops hit auto/finalize/review phases that reset the count). 0 disables.
161
+ PROXY_FORCED_HARD_RELEASE = int(os.environ.get("PROXY_FORCED_HARD_RELEASE", "30"))
145
162
  PROXY_CONTEXT_RELEASE_THRESHOLD = float(
146
163
  os.environ.get("PROXY_CONTEXT_RELEASE_THRESHOLD", "0.90")
147
164
  )
@@ -250,6 +267,34 @@ PROXY_FINALIZE_SESSION_HARD_CAP = int(
250
267
  PROXY_RECON_CONVERGENCE_THRESHOLD = int(
251
268
  os.environ.get("PROXY_RECON_CONVERGENCE_THRESHOLD", "40")
252
269
  )
270
+ # Fix E: the recon hard tier (streak >= 2x threshold) fires a directive + flips
271
+ # tool_choice to 'auto', but `consecutive_no_write_turns` resets to 0 whenever
272
+ # the model emits any write tool — so a loop that periodically writes sawtooths
273
+ # the streak (observed: 90 -> 0 -> climb again), re-triggering the hard tier
274
+ # forever and never actually terminating. This counts how many times the hard
275
+ # tier has fired across the whole session (monotonic, never reset). Once it
276
+ # reaches this cap, the guard escalates: it strips tools for the turn so the
277
+ # model is forced to emit a terminal prose summary, breaking the sawtooth. 0
278
+ # disables the escalation (hard tier still flips to 'auto' each time).
279
+ PROXY_RECON_SESSION_HARD_CAP = int(
280
+ os.environ.get("PROXY_RECON_SESSION_HARD_CAP", "3")
281
+ )
282
+ # Fix F: context death-spiral breaker. When the *raw* (pre-prune) incoming
283
+ # context stays catastrophically over the window for several consecutive turns,
284
+ # releasing tool_choice to 'auto' (Fix B / LOOP BREAKER) is NOT enough — the
285
+ # model keeps voluntarily emitting tool calls and the client keeps resending an
286
+ # ever-growing transcript (observed: ctx 936%, model emits tool_calls 18/min
287
+ # despite tool_choice=auto). After this many consecutive turns at/above the
288
+ # ratio, strip tools entirely so the only possible response is a terminal text
289
+ # summary (end_turn), which ends the client's agentic loop. Ratio is set high
290
+ # enough that only a true runaway trips it — a merely-full session tops out near
291
+ # 100-130%, never 300%. 0 disables.
292
+ PROXY_RAW_CTX_FINALIZE_RATIO = float(
293
+ os.environ.get("PROXY_RAW_CTX_FINALIZE_RATIO", "3.0")
294
+ )
295
+ PROXY_RAW_CTX_FINALIZE_STREAK = int(
296
+ os.environ.get("PROXY_RAW_CTX_FINALIZE_STREAK", "2")
297
+ )
253
298
  PROXY_STREAM_REASONING_FALLBACK = (
254
299
  os.environ.get("PROXY_STREAM_REASONING_FALLBACK", "off").strip().lower()
255
300
  )
@@ -792,6 +837,13 @@ class SessionMonitor:
792
837
  last_input_tokens: int = 0 # Estimated input tokens of last request
793
838
  last_output_tokens: int = 0 # Actual output tokens of last response
794
839
  peak_input_tokens: int = 0 # High-water mark
840
+ # Fix B: the incoming (pre-prune) token count for the current request. The
841
+ # proxy prunes the conversation and then calls record_request() again with
842
+ # the post-prune total, so last_input_tokens / get_utilization() reflect the
843
+ # *pruned* size (~30%) by the time the tool_choice guards run — masking the
844
+ # fact that the client just sent e.g. 800% of the window. This preserves the
845
+ # raw size so LOOP BREAKER pattern 3 can release on real context blow-up.
846
+ pre_prune_input_tokens: int = 0
795
847
  prune_count: int = 0 # How many times pruning was triggered
796
848
  overflow_count: int = 0 # How many context overflow errors caught
797
849
  prune_drop_count: int = 0 # monotonic: # of oldest middle msgs pruned (B3)
@@ -810,6 +862,8 @@ class SessionMonitor:
810
862
  loop_warnings_emitted: int = 0 # How many loop warnings sent to the model
811
863
  no_progress_streak: int = 0 # Forced tool turns without new tool_result
812
864
  consecutive_no_write_turns: int = 0 # turns exploring with no write tool (B1)
865
+ recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
866
+ catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
813
867
  unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
814
868
  tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
815
869
  malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
@@ -870,6 +924,18 @@ class SessionMonitor:
870
924
  return 0.0
871
925
  return self.last_input_tokens / self.context_window
872
926
 
927
+ def get_raw_utilization(self) -> float:
928
+ """Pre-prune context utilization for the current request (Fix B).
929
+
930
+ Reflects what the client actually sent this turn, before the proxy
931
+ pruned it. Used by the loop breaker so a runaway client that resends
932
+ 800% of the window each turn is detected even though post-prune
933
+ utilization reads ~30%. Returns 0.0 until the first request is recorded.
934
+ """
935
+ if self.context_window <= 0:
936
+ return 0.0
937
+ return self.pre_prune_input_tokens / self.context_window
938
+
873
939
  def get_warning_level(self) -> str | None:
874
940
  """Return warning level based on context utilization.
875
941
  Returns None if no warning needed."""
@@ -1230,12 +1296,37 @@ class SessionMonitor:
1230
1296
  self.loop_warnings_emitted += 1
1231
1297
  return True
1232
1298
 
1233
- # Pattern 3: Context almost full -- let model wrap up naturally
1234
- if self.get_utilization() >= PROXY_CONTEXT_RELEASE_THRESHOLD:
1299
+ # Pattern 2b (Fix D): streak-independent forced-count ceiling. In an
1300
+ # agentic loop no_progress_streak resets every turn (tool_result always
1301
+ # present), so Pattern 2 never fires. consecutive_forced_count, however,
1302
+ # accumulates across the loop. Release once it crosses the hard ceiling
1303
+ # regardless of no_progress_streak so the model can terminate.
1304
+ if (
1305
+ PROXY_FORCED_HARD_RELEASE > 0
1306
+ and self.consecutive_forced_count >= PROXY_FORCED_HARD_RELEASE
1307
+ ):
1308
+ logger.warning(
1309
+ "LOOP BREAKER: %d consecutive forced tool_choice requests (hard ceiling %d) -- "
1310
+ "releasing to 'auto' regardless of progress streak.",
1311
+ self.consecutive_forced_count,
1312
+ PROXY_FORCED_HARD_RELEASE,
1313
+ )
1314
+ self.loop_warnings_emitted += 1
1315
+ return True
1316
+
1317
+ # Pattern 3: Context almost full -- let model wrap up naturally.
1318
+ # Fix B: check BOTH post-prune utilization and the raw pre-prune size.
1319
+ # The proxy prunes before this runs, so get_utilization() reads ~30%
1320
+ # even when the client just sent 800% of the window; get_raw_utilization()
1321
+ # exposes the real blow-up so a runaway client is actually released.
1322
+ eff_util = max(self.get_utilization(), self.get_raw_utilization())
1323
+ if eff_util >= PROXY_CONTEXT_RELEASE_THRESHOLD:
1235
1324
  logger.warning(
1236
- "LOOP BREAKER: Context utilization %.1f%% -- releasing "
1237
- "tool_choice to let model wrap up.",
1325
+ "LOOP BREAKER: Context utilization %.1f%% (post-prune %.1f%%, raw %.1f%%) -- "
1326
+ "releasing tool_choice to let model wrap up.",
1327
+ eff_util * 100,
1238
1328
  self.get_utilization() * 100,
1329
+ self.get_raw_utilization() * 100,
1239
1330
  )
1240
1331
  return True
1241
1332
 
@@ -3389,8 +3480,32 @@ def _maybe_inject_recon_convergence(
3389
3480
  streak = monitor.consecutive_no_write_turns
3390
3481
  if streak < PROXY_RECON_CONVERGENCE_THRESHOLD:
3391
3482
  return
3392
- util = monitor.get_utilization()
3393
- if streak >= 2 * PROXY_RECON_CONVERGENCE_THRESHOLD:
3483
+ # Report the *raw* (pre-prune) utilization — post-prune util understates the
3484
+ # blow-up (~30%) and makes the directive's "context is at X%" misleading.
3485
+ util = max(monitor.get_utilization(), monitor.get_raw_utilization())
3486
+ hard = streak >= 2 * PROXY_RECON_CONVERGENCE_THRESHOLD
3487
+ escalate = False
3488
+ if hard:
3489
+ monitor.recon_hard_fires += 1 # Fix E: monotonic, never reset
3490
+ escalate = (
3491
+ PROXY_RECON_SESSION_HARD_CAP > 0
3492
+ and monitor.recon_hard_fires >= PROXY_RECON_SESSION_HARD_CAP
3493
+ )
3494
+
3495
+ if escalate:
3496
+ # Fix E: the hard tier has fired repeatedly this session — the model
3497
+ # keeps writing just enough to reset consecutive_no_write_turns, then
3498
+ # re-diverges, sawtoothing the streak and re-triggering the hard tier
3499
+ # forever. Stop negotiating: strip tools so the model MUST emit a
3500
+ # terminal plain-text summary, breaking the sawtooth for good.
3501
+ directive = (
3502
+ f"STOP. You have hit the exploration limit {monitor.recon_hard_fires} "
3503
+ f"times in this session and context is at {util * 100:.0f}%. No tools "
3504
+ "are available this turn. Reply NOW with a plain-text summary of what "
3505
+ "you found and what remains — this ends the task."
3506
+ )
3507
+ tier = "hard-escalated"
3508
+ elif hard:
3394
3509
  directive = (
3395
3510
  f"STOP exploring. You have run {streak} consecutive turns of "
3396
3511
  f"exploration without producing a deliverable and context is at "
@@ -3413,27 +3528,43 @@ def _maybe_inject_recon_convergence(
3413
3528
  msgs.append({"role": "user", "content": directive})
3414
3529
  openai_body["messages"] = msgs
3415
3530
 
3416
- # Re-inject any write/deliverable tool that narrowing dropped, so the
3417
- # "write your deliverable" directive is actually satisfiable. Without
3418
- # this the model is told to write but has no write tool to call, picks
3419
- # another read tool, and the streak climbs unbounded.
3420
3531
  restored: list[str] = []
3421
- if full_tools:
3422
- present = {
3423
- (t.get("function", {}).get("name", "") or "").lower()
3424
- for t in openai_body.get("tools", [])
3425
- }
3426
- for tool in full_tools:
3427
- name = (tool.get("function", {}).get("name", "") or "")
3428
- if name.lower() in _WRITE_TOOL_CLASS and name.lower() not in present:
3429
- openai_body.setdefault("tools", []).append(tool)
3430
- present.add(name.lower())
3431
- restored.append(name)
3532
+ if escalate:
3533
+ # Strip tools entirely so the only possible response is terminal prose.
3534
+ openai_body.pop("tools", None)
3535
+ openai_body.pop("tool_choice", None)
3536
+ openai_body.pop("grammar", None)
3537
+ else:
3538
+ if hard:
3539
+ # Fix C: at the hard tier, drop the structural requirement to call a
3540
+ # tool. Earlier logic forced tool_choice='required' for the active
3541
+ # agentic loop, which directly contradicts "produce your deliverable
3542
+ # NOW / do not run anything else" — the model is forbidden from
3543
+ # terminating and must emit yet another tool call, so the streak
3544
+ # climbs unbounded. Releasing to 'auto' lets it actually write/stop.
3545
+ openai_body["tool_choice"] = "auto"
3546
+ openai_body.pop("grammar", None)
3547
+ # Re-inject any write/deliverable tool that narrowing dropped, so the
3548
+ # "write your deliverable" directive is actually satisfiable. Without
3549
+ # this the model is told to write but has no write tool to call, picks
3550
+ # another read tool, and the streak climbs unbounded.
3551
+ if full_tools:
3552
+ present = {
3553
+ (t.get("function", {}).get("name", "") or "").lower()
3554
+ for t in openai_body.get("tools", [])
3555
+ }
3556
+ for tool in full_tools:
3557
+ name = (tool.get("function", {}).get("name", "") or "")
3558
+ if name.lower() in _WRITE_TOOL_CLASS and name.lower() not in present:
3559
+ openai_body.setdefault("tools", []).append(tool)
3560
+ present.add(name.lower())
3561
+ restored.append(name)
3432
3562
 
3433
3563
  logger.warning(
3434
- "RECON CONVERGENCE: injected %s directive (no_write_streak=%d, ctx=%.0f%%, "
3435
- "restored_write_tools=%s)",
3436
- tier, streak, util * 100, restored or "none",
3564
+ "RECON CONVERGENCE: injected %s directive (no_write_streak=%d, hard_fires=%d, "
3565
+ "ctx=%.0f%%, tool_choice=%s, restored_write_tools=%s)",
3566
+ tier, streak, monitor.recon_hard_fires, util * 100,
3567
+ openai_body.get("tool_choice", "stripped"), restored or "none",
3437
3568
  )
3438
3569
 
3439
3570
 
@@ -3743,6 +3874,46 @@ def build_openai_request(
3743
3874
  last_user_has_tool_result,
3744
3875
  )
3745
3876
 
3877
+ # CONTEXT DEATH-SPIRAL BREAKER (Fix F): raw incoming context has been
3878
+ # catastrophically over the window for several consecutive turns. The
3879
+ # LOOP BREAKER already released tool_choice to 'auto', but the model
3880
+ # keeps voluntarily emitting tool calls and the client keeps resending a
3881
+ # growing transcript, so the loop never ends. Strip tools entirely so
3882
+ # the only possible output is a terminal text summary (end_turn), which
3883
+ # ends the client's agentic loop. Gated high (raw ctx >= 300% for >= N
3884
+ # turns) so only a true runaway trips it, never a merely-full session.
3885
+ if (
3886
+ PROXY_RAW_CTX_FINALIZE_STREAK > 0
3887
+ and monitor.catastrophic_ctx_streak >= PROXY_RAW_CTX_FINALIZE_STREAK
3888
+ ):
3889
+ openai_body.pop("tool_choice", None)
3890
+ openai_body.pop("tools", None)
3891
+ openai_body.pop("grammar", None)
3892
+ msgs = openai_body.get("messages", [])
3893
+ msgs.append({
3894
+ "role": "user",
3895
+ "content": (
3896
+ "The conversation has exceeded the context window "
3897
+ f"({monitor.get_raw_utilization() * 100:.0f}%) and cannot "
3898
+ "continue. No tools are available. Reply with a brief "
3899
+ "plain-text summary of what was accomplished and what "
3900
+ "remains, then stop."
3901
+ ),
3902
+ })
3903
+ openai_body["messages"] = msgs
3904
+ monitor.reset_tool_turn_state(reason="context_death_spiral_breaker")
3905
+ logger.error(
3906
+ "CONTEXT DEATH-SPIRAL BREAKER: raw ctx %.0f%% for %d consecutive "
3907
+ "turns -- stripped tools to force terminal summary (end_turn).",
3908
+ monitor.get_raw_utilization() * 100,
3909
+ monitor.catastrophic_ctx_streak,
3910
+ )
3911
+ if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
3912
+ openai_body["enable_thinking"] = False
3913
+ if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
3914
+ openai_body["speculative.n_max"] = 0
3915
+ return openai_body
3916
+
3746
3917
  # TOOL STARVATION BREAKER: if model repeatedly fails to produce tool
3747
3918
  # calls despite required, strip tools to let it generate text and break
3748
3919
  # the forcing loop.
@@ -7679,10 +7850,15 @@ async def _passthrough_anthropic_request(
7679
7850
  )
7680
7851
 
7681
7852
  url = f"{ANTHROPIC_API_BASE.rstrip('/')}/v1/messages"
7853
+ # Bounded timeout so a slow/stuck Anthropic generation can't hang the
7854
+ # request (and the single upstream slot) for the full default read timeout.
7855
+ pt_timeout = httpx.Timeout(
7856
+ connect=10.0, read=PROXY_PASSTHROUGH_TIMEOUT, write=30.0, pool=10.0
7857
+ )
7682
7858
 
7683
7859
  if is_stream:
7684
7860
  resp = await client.send(
7685
- client.build_request("POST", url, json=body, headers=headers)
7861
+ client.build_request("POST", url, json=body, headers=headers, timeout=pt_timeout)
7686
7862
  )
7687
7863
  if resp.status_code != 200:
7688
7864
  return Response(
@@ -7696,7 +7872,7 @@ async def _passthrough_anthropic_request(
7696
7872
  media_type=resp.headers.get("content-type", "text/event-stream"),
7697
7873
  )
7698
7874
 
7699
- resp = await client.post(url, json=body, headers=headers)
7875
+ resp = await client.post(url, json=body, headers=headers, timeout=pt_timeout)
7700
7876
  return Response(
7701
7877
  content=resp.content,
7702
7878
  status_code=resp.status_code,
@@ -7821,6 +7997,20 @@ async def messages(request: Request):
7821
7997
  estimated_tokens,
7822
7998
  )
7823
7999
  utilization = effective_tokens / ctx_window
8000
+ # Fix B: preserve the raw incoming size before any pruning rewrites
8001
+ # last_input_tokens to the post-prune total, so the loop breaker can
8002
+ # see the true blow-up at build_openai_request time.
8003
+ monitor.pre_prune_input_tokens = effective_tokens
8004
+ # Fix F: track consecutive turns whose raw incoming context is
8005
+ # catastrophically over the window (a death spiral the per-request
8006
+ # pruner can mask but not cure). build_openai_request acts on this.
8007
+ if (
8008
+ PROXY_RAW_CTX_FINALIZE_RATIO > 0
8009
+ and utilization >= PROXY_RAW_CTX_FINALIZE_RATIO
8010
+ ):
8011
+ monitor.catastrophic_ctx_streak += 1
8012
+ else:
8013
+ monitor.catastrophic_ctx_streak = 0
7824
8014
  if utilization >= PROXY_CONTEXT_PRUNE_THRESHOLD:
7825
8015
  logger.warning(
7826
8016
  "Context utilization %.1f%% exceeds threshold %.1f%% -- pruning conversation",
@@ -4,6 +4,7 @@ import asyncio
4
4
  import importlib.util
5
5
  import json
6
6
  import unittest
7
+ import unittest.mock
7
8
  from pathlib import Path
8
9
 
9
10
  import httpx
@@ -5661,3 +5662,42 @@ class TestPrunerRework(unittest.TestCase):
5661
5662
  self.assertIn("CONTEXT PRUNED", summary)
5662
5663
  self.assertNotIn("tool result", summary)
5663
5664
  self.assertNotIn("most recent", summary)
5665
+
5666
+
5667
+ class TestPassthroughTimeout(unittest.IsolatedAsyncioTestCase):
5668
+ """Anthropic-passthrough upstream calls must be bounded so a slow/stuck
5669
+ generation cannot hang the request (and the single upstream slot) for the
5670
+ full default read timeout (~the ~77-min benchmark hangs)."""
5671
+
5672
+ def test_passthrough_timeout_default(self):
5673
+ self.assertEqual(proxy.PROXY_PASSTHROUGH_TIMEOUT, 600.0)
5674
+
5675
+ async def test_nonstream_passthrough_applies_bounded_timeout(self):
5676
+ captured = {}
5677
+
5678
+ async def fake_post(url, json=None, headers=None, timeout=None):
5679
+ captured["timeout"] = timeout
5680
+ r = _FakeResponse({"id": "msg_x"}, status_code=200)
5681
+ r.content = b'{"id":"msg_x"}'
5682
+ r.headers = {"content-type": "application/json"}
5683
+ return r
5684
+
5685
+ class _Client:
5686
+ post = staticmethod(fake_post)
5687
+
5688
+ orig_client = proxy.http_client
5689
+ orig_key = proxy.ANTHROPIC_API_KEY
5690
+ proxy.http_client = _Client()
5691
+ proxy.ANTHROPIC_API_KEY = "sk-test"
5692
+ try:
5693
+ req = unittest.mock.MagicMock()
5694
+ req.headers = {}
5695
+ await proxy._passthrough_anthropic_request(
5696
+ req, {"model": "claude-sonnet-4-6", "messages": []}, is_stream=False
5697
+ )
5698
+ finally:
5699
+ proxy.http_client = orig_client
5700
+ proxy.ANTHROPIC_API_KEY = orig_key
5701
+
5702
+ self.assertIsNotNone(captured.get("timeout"))
5703
+ self.assertEqual(captured["timeout"].read, proxy.PROXY_PASSTHROUGH_TIMEOUT)