@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/memory.js +3 -1
- package/dist/cli/memory.js.map +1 -1
- package/dist/cli/patterns.js +2 -2
- package/dist/cli/patterns.js.map +1 -1
- package/dist/memory/backends/qdrant-cloud.d.ts.map +1 -1
- package/dist/memory/backends/qdrant-cloud.js +2 -1
- package/dist/memory/backends/qdrant-cloud.js.map +1 -1
- package/docs/INDEX.md +2 -2
- package/docs/guides/AUTOMATIC.md +255 -61
- package/docs/guides/AUTOMATIC_FEATURES.md +164 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +216 -26
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +40 -0
|
@@ -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
|
|
1234
|
-
|
|
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%% --
|
|
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
|
-
|
|
3393
|
-
|
|
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
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
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,
|
|
3435
|
-
"restored_write_tools=%s)",
|
|
3436
|
-
tier, streak, util * 100,
|
|
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)
|