@miller-tech/uap 1.210.3 → 1.210.5
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 +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.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 +140 -29
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +11 -0
- package/tools/agents/tests/test_models_context_window.py +123 -6
- package/tools/agents/tests/test_thinking_template_kwargs.py +155 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.210.
|
|
3
|
+
"version": "1.210.5",
|
|
4
4
|
"description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node dist/bin/cli.js",
|
|
22
22
|
"test": "vitest",
|
|
23
23
|
"test:ci": "vitest run",
|
|
24
|
-
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -54,8 +54,10 @@ Configuration (Environment Variables)
|
|
|
54
54
|
PROXY_MAX_CONNECTIONS Max concurrent connections to upstream
|
|
55
55
|
Default: 20
|
|
56
56
|
|
|
57
|
-
PROXY_CONTEXT_WINDOW
|
|
58
|
-
upstream /slots
|
|
57
|
+
PROXY_CONTEXT_WINDOW Startup FALLBACK for the context window, used only
|
|
58
|
+
when the upstream /slots probe fails. The detected
|
|
59
|
+
rail wins (re-checked every 60s) and is what
|
|
60
|
+
/v1/models advertises. Not a cap.
|
|
59
61
|
Default: 0 (auto-detect)
|
|
60
62
|
|
|
61
63
|
PROXY_CONTEXT_PRUNE_THRESHOLD Fraction of context window at which
|
|
@@ -1451,12 +1453,48 @@ def _apply_json_response_grammar(openai_body: dict, anthropic_body: dict) -> Non
|
|
|
1451
1453
|
if openai_body.get("tools") or openai_body.get("grammar"):
|
|
1452
1454
|
return
|
|
1453
1455
|
openai_body["grammar"] = JSON_RESPONSE_GBNF
|
|
1454
|
-
openai_body
|
|
1455
|
-
ctk = openai_body.setdefault("chat_template_kwargs", {})
|
|
1456
|
-
ctk["enable_thinking"] = False
|
|
1456
|
+
_set_thinking(openai_body, False)
|
|
1457
1457
|
logger.info("JSON-RESPONSE grammar applied (evaluator verdict turn)")
|
|
1458
1458
|
|
|
1459
1459
|
|
|
1460
|
+
def _set_thinking(body: dict, enabled: bool) -> None:
|
|
1461
|
+
"""Turn upstream reasoning on/off, in BOTH places llama.cpp reads.
|
|
1462
|
+
|
|
1463
|
+
The top-level `enable_thinking` field alone does NOTHING on a `--jinja`
|
|
1464
|
+
server: the Qwen chat template reads `chat_template_kwargs`, and this one is
|
|
1465
|
+
launched with `--chat-template-kwargs {"enable_thinking": true}`, so an
|
|
1466
|
+
unqualified top-level flag is silently overridden by the template default.
|
|
1467
|
+
|
|
1468
|
+
Measured on the live server, identical tool-call prompt:
|
|
1469
|
+
top-level only -> 703 chars of reasoning, 196 tokens
|
|
1470
|
+
chat_template_kwargs -> 0 chars, 27 tokens
|
|
1471
|
+
and on a planning-flavoured prompt with max_tokens=700, reasoning consumed
|
|
1472
|
+
the ENTIRE budget and no tool call was emitted at all.
|
|
1473
|
+
|
|
1474
|
+
The OPERATOR switches — PROXY_DISABLE_THINKING_ALWAYS and
|
|
1475
|
+
PROXY_DISABLE_THINKING_ON_TOOL_TURNS, plus the tool-turn breakers — set only
|
|
1476
|
+
the top-level field, so they were no-ops against a jinja server: they
|
|
1477
|
+
existed, logged themselves as active, and changed nothing. The paths that
|
|
1478
|
+
already set both (the JSON-verdict grammar, the empty-max_tokens retry, and
|
|
1479
|
+
the prefill/continuation turn) worked, which is why evaluator turns were
|
|
1480
|
+
unaffected and this went unnoticed elsewhere.
|
|
1481
|
+
|
|
1482
|
+
Use this for a switch that expresses INTENT ("this turn must not reason").
|
|
1483
|
+
The Anthropic protocol-default translation deliberately does NOT use it —
|
|
1484
|
+
see the comment there; making a default authoritative would change serving
|
|
1485
|
+
policy for every client rather than fix a broken control.
|
|
1486
|
+
"""
|
|
1487
|
+
body["enable_thinking"] = enabled
|
|
1488
|
+
# REPLACE the nested dict rather than mutate it: callers build retry bodies
|
|
1489
|
+
# with `dict(openai_body)`, a SHALLOW copy that still shares this dict, so
|
|
1490
|
+
# mutating in place would silently disable thinking on the original request
|
|
1491
|
+
# too. Copying keeps any other template kwargs the server was launched with.
|
|
1492
|
+
existing = body.get("chat_template_kwargs")
|
|
1493
|
+
ctk = dict(existing) if isinstance(existing, dict) else {}
|
|
1494
|
+
ctk["enable_thinking"] = enabled
|
|
1495
|
+
body["chat_template_kwargs"] = ctk
|
|
1496
|
+
|
|
1497
|
+
|
|
1460
1498
|
def _apply_thinking_grammar(request_body: dict) -> None:
|
|
1461
1499
|
"""Apply the structured-thinking GBNF grammar to non-tool turns.
|
|
1462
1500
|
|
|
@@ -1545,6 +1583,13 @@ def _apply_profile_overrides(
|
|
|
1545
1583
|
if "stop_sequences" in profile:
|
|
1546
1584
|
updated["stop_sequences"] = profile["stop_sequences"]
|
|
1547
1585
|
if "enable_thinking" in profile:
|
|
1586
|
+
# Left as a plain assignment: `updated` is the ANTHROPIC-shaped body,
|
|
1587
|
+
# and build_openai_request constructs the upstream body from a fresh
|
|
1588
|
+
# literal that never reads this key — so this has never reached the
|
|
1589
|
+
# wire, and writing a llama.cpp template kwarg into an Anthropic body
|
|
1590
|
+
# would only add a field that passthrough requests must not carry.
|
|
1591
|
+
# Wiring profiles to thinking is a separate change from making the
|
|
1592
|
+
# operator switches work.
|
|
1548
1593
|
updated["enable_thinking"] = profile["enable_thinking"]
|
|
1549
1594
|
|
|
1550
1595
|
tool_call_batching = profile.get("tool_call_batching") or {}
|
|
@@ -2407,6 +2452,11 @@ class SessionMonitor:
|
|
|
2407
2452
|
|
|
2408
2453
|
session_monitors: dict[str, SessionMonitor] = {}
|
|
2409
2454
|
default_context_window = 0
|
|
2455
|
+
# True only when `default_context_window` came from the SERVER (a /slots read)
|
|
2456
|
+
# or from an explicit operator setting — never when it is the hardcoded
|
|
2457
|
+
# fallback. /v1/models publishes the window to clients as fact, and a guess
|
|
2458
|
+
# published as fact is the failure `_model_entry` exists to prevent.
|
|
2459
|
+
_context_window_measured: bool = False
|
|
2410
2460
|
last_session_id = ""
|
|
2411
2461
|
_last_ctx_recheck_ts: float = 0.0
|
|
2412
2462
|
_CTX_RECHECK_INTERVAL: float = 60.0 # Re-detect context window every 60s
|
|
@@ -2476,13 +2526,41 @@ def _cleanup_stale_monitors(now_ts: float) -> None:
|
|
|
2476
2526
|
session_monitors.pop(sid, None)
|
|
2477
2527
|
|
|
2478
2528
|
|
|
2529
|
+
def _effective_context_window() -> int:
|
|
2530
|
+
"""The window this proxy actually ENFORCES, in tokens (0 = unknown).
|
|
2531
|
+
|
|
2532
|
+
The detected value wins over the env pin because the pin goes stale: the
|
|
2533
|
+
upstream server can restart with a different --ctx-size, and
|
|
2534
|
+
`_maybe_recheck_context_window` tracks that. `PROXY_CONTEXT_WINDOW` is the
|
|
2535
|
+
fallback for when detection has not run or could not reach the server.
|
|
2536
|
+
|
|
2537
|
+
This is the idiom already used at the count_tokens compaction-forcing call
|
|
2538
|
+
sites; it exists as a function so the process-wide consumers — the forcing
|
|
2539
|
+
scale and the /v1/models advertisement — resolve the window from ONE
|
|
2540
|
+
source. They did not: /v1/models stamped the raw env value while everything
|
|
2541
|
+
else used the detected rail. Live on 2026-08-16 that meant the endpoint
|
|
2542
|
+
advertised 65,536 while the proxy enforced 199,680, and the two errors
|
|
2543
|
+
compounded. A client sizing itself to 65,536 while receiving counts
|
|
2544
|
+
inflated 1.73x for compaction forcing compacts at ~35k REAL tokens — 18%
|
|
2545
|
+
of the rail it was given.
|
|
2546
|
+
|
|
2547
|
+
NOT every window in the process: a request carrying a model profile
|
|
2548
|
+
overrides `monitor.context_window` (see `messages`), and the pruner reads
|
|
2549
|
+
that per-session value. This function is the process default, not a claim
|
|
2550
|
+
about every session.
|
|
2551
|
+
"""
|
|
2552
|
+
if default_context_window > 0:
|
|
2553
|
+
return default_context_window
|
|
2554
|
+
return max(PROXY_CONTEXT_WINDOW, 0)
|
|
2555
|
+
|
|
2556
|
+
|
|
2479
2557
|
async def _maybe_recheck_context_window() -> None:
|
|
2480
2558
|
"""Periodically re-query the upstream server's context window.
|
|
2481
2559
|
|
|
2482
2560
|
Handles server restarts with different --ctx-size mid-session.
|
|
2483
2561
|
Non-blocking: skips if the check interval hasn't elapsed.
|
|
2484
2562
|
"""
|
|
2485
|
-
global default_context_window, _last_ctx_recheck_ts
|
|
2563
|
+
global default_context_window, _last_ctx_recheck_ts, _context_window_measured
|
|
2486
2564
|
now = time.time()
|
|
2487
2565
|
if now - _last_ctx_recheck_ts < _CTX_RECHECK_INTERVAL:
|
|
2488
2566
|
return
|
|
@@ -2496,6 +2574,8 @@ async def _maybe_recheck_context_window() -> None:
|
|
|
2496
2574
|
slots = resp.json()
|
|
2497
2575
|
if slots and isinstance(slots, list):
|
|
2498
2576
|
n_ctx = slots[0].get("n_ctx", 0)
|
|
2577
|
+
if n_ctx > 0:
|
|
2578
|
+
_context_window_measured = True
|
|
2499
2579
|
if n_ctx > 0 and n_ctx != default_context_window:
|
|
2500
2580
|
old = default_context_window
|
|
2501
2581
|
default_context_window = n_ctx
|
|
@@ -2533,10 +2613,17 @@ async def detect_context_window(client: httpx.AsyncClient) -> int:
|
|
|
2533
2613
|
|
|
2534
2614
|
Queries the /slots endpoint (llama.cpp) to get the actual n_ctx value.
|
|
2535
2615
|
Falls back to PROXY_CONTEXT_WINDOW env var, then to a safe default.
|
|
2616
|
+
|
|
2617
|
+
The probe runs FIRST, which is what this docstring always claimed but the
|
|
2618
|
+
code did not do: it returned the env value without asking the server, so a
|
|
2619
|
+
stale setting governed until the first /v1/messages request triggered the
|
|
2620
|
+
60s recheck — and SDK clients read /v1/models before ever sending a message.
|
|
2621
|
+
The env value is a hand-maintained copy of this same number (the operator
|
|
2622
|
+
file that carries it says "re-derive it whenever --parallel or --ctx-size
|
|
2623
|
+
moves"), so asking the server is strictly better information; the setting
|
|
2624
|
+
stays as the answer for when the server cannot be reached.
|
|
2536
2625
|
"""
|
|
2537
|
-
|
|
2538
|
-
logger.info("Using configured context window: %d tokens", PROXY_CONTEXT_WINDOW)
|
|
2539
|
-
return PROXY_CONTEXT_WINDOW
|
|
2626
|
+
global _context_window_measured
|
|
2540
2627
|
|
|
2541
2628
|
try:
|
|
2542
2629
|
slots_url = LLAMA_CPP_BASE.replace("/v1", "/slots")
|
|
@@ -2551,13 +2638,21 @@ async def detect_context_window(client: httpx.AsyncClient) -> int:
|
|
|
2551
2638
|
n_ctx,
|
|
2552
2639
|
len(slots),
|
|
2553
2640
|
)
|
|
2641
|
+
_context_window_measured = True
|
|
2554
2642
|
return n_ctx
|
|
2555
2643
|
except Exception as exc:
|
|
2556
2644
|
logger.warning("Failed to auto-detect context window: %s", exc)
|
|
2557
2645
|
|
|
2558
|
-
|
|
2646
|
+
if PROXY_CONTEXT_WINDOW > 0:
|
|
2647
|
+
logger.info("Using configured context window: %d tokens", PROXY_CONTEXT_WINDOW)
|
|
2648
|
+
_context_window_measured = True # an operator setting is an assertion
|
|
2649
|
+
return PROXY_CONTEXT_WINDOW
|
|
2650
|
+
|
|
2651
|
+
# Safe default: 128K (common for modern models). NOT measured — the pruner
|
|
2652
|
+
# may use it as a backstop, but it must never be published as fact.
|
|
2559
2653
|
default = 131072
|
|
2560
2654
|
logger.warning("Using default context window: %d tokens", default)
|
|
2655
|
+
_context_window_measured = False
|
|
2561
2656
|
return default
|
|
2562
2657
|
|
|
2563
2658
|
|
|
@@ -6401,14 +6496,26 @@ def build_openai_request(
|
|
|
6401
6496
|
# consume the client's max_tokens budget on internal reasoning, leaving
|
|
6402
6497
|
# nothing for the visible answer.
|
|
6403
6498
|
anthropic_thinking = anthropic_body.get("thinking")
|
|
6499
|
+
# PROTOCOL DEFAULT — deliberately top-level only, NOT via _set_thinking.
|
|
6500
|
+
#
|
|
6501
|
+
# Anthropic's default is "thinking off unless asked", so this branch sets
|
|
6502
|
+
# off for almost every request. Against a jinja server the top-level field
|
|
6503
|
+
# is inert, which is why the upstream has been reasoning by default all
|
|
6504
|
+
# along. Routing this through _set_thinking would make it authoritative and
|
|
6505
|
+
# silently flip EVERY client that never asked for thinking — Claude Code,
|
|
6506
|
+
# opencode, benchmarks, all OpenAI-compat traffic — from the server's
|
|
6507
|
+
# configured default to off. That is a serving-policy change, not a bug fix.
|
|
6508
|
+
#
|
|
6509
|
+
# The same reasoning applies to the "enabled" case in reverse: making it
|
|
6510
|
+
# authoritative would let a client turn reasoning ON against a server
|
|
6511
|
+
# deliberately launched with it off. The measured bug was one-directional
|
|
6512
|
+
# (an operator's OFF switch didn't stick); the fix stays one-directional.
|
|
6513
|
+
# Operators who want thinking off across the board have an explicit switch
|
|
6514
|
+
# below (PROXY_DISABLE_THINKING_ALWAYS), and it now works.
|
|
6404
6515
|
if isinstance(anthropic_thinking, dict):
|
|
6405
6516
|
ttype = (anthropic_thinking.get("type") or "").lower()
|
|
6406
|
-
|
|
6407
|
-
openai_body["enable_thinking"] = True
|
|
6408
|
-
else:
|
|
6409
|
-
openai_body["enable_thinking"] = False
|
|
6517
|
+
openai_body["enable_thinking"] = ttype == "enabled"
|
|
6410
6518
|
else:
|
|
6411
|
-
# Match Anthropic default: thinking off unless explicitly requested.
|
|
6412
6519
|
openai_body["enable_thinking"] = False
|
|
6413
6520
|
|
|
6414
6521
|
# Global thinking-off (G): apply to every request, not just tool turns.
|
|
@@ -6416,7 +6523,7 @@ def build_openai_request(
|
|
|
6416
6523
|
# Per-path tool-turn handling below (DISABLE_THINKING_ON_TOOL_TURNS) is
|
|
6417
6524
|
# additive — ALWAYS supersedes when set.
|
|
6418
6525
|
if PROXY_DISABLE_THINKING_ALWAYS:
|
|
6419
|
-
openai_body
|
|
6526
|
+
_set_thinking(openai_body, False)
|
|
6420
6527
|
|
|
6421
6528
|
# Inject agentic protocol instructions only for tool-enabled turns.
|
|
6422
6529
|
# Use minimal supplement for qwen models to reduce prompt leak surface.
|
|
@@ -6736,7 +6843,7 @@ def build_openai_request(
|
|
|
6736
6843
|
monitor.catastrophic_ctx_streak,
|
|
6737
6844
|
)
|
|
6738
6845
|
if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
|
|
6739
|
-
openai_body
|
|
6846
|
+
_set_thinking(openai_body, False)
|
|
6740
6847
|
if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
|
|
6741
6848
|
openai_body["speculative.n_max"] = 0
|
|
6742
6849
|
return openai_body
|
|
@@ -6761,7 +6868,7 @@ def build_openai_request(
|
|
|
6761
6868
|
)
|
|
6762
6869
|
# Skip all further tool_choice logic — no tools this turn
|
|
6763
6870
|
if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
|
|
6764
|
-
openai_body
|
|
6871
|
+
_set_thinking(openai_body, False)
|
|
6765
6872
|
if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
|
|
6766
6873
|
openai_body["speculative.n_max"] = 0
|
|
6767
6874
|
return openai_body
|
|
@@ -6811,7 +6918,7 @@ def build_openai_request(
|
|
|
6811
6918
|
PROXY_HARD_FINALIZE_TURNS,
|
|
6812
6919
|
)
|
|
6813
6920
|
if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
|
|
6814
|
-
openai_body
|
|
6921
|
+
_set_thinking(openai_body, False)
|
|
6815
6922
|
if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
|
|
6816
6923
|
openai_body["speculative.n_max"] = 0
|
|
6817
6924
|
return openai_body
|
|
@@ -6994,7 +7101,7 @@ def build_openai_request(
|
|
|
6994
7101
|
|
|
6995
7102
|
|
|
6996
7103
|
if PROXY_DISABLE_THINKING_ALWAYS or PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
|
|
6997
|
-
openai_body
|
|
7104
|
+
_set_thinking(openai_body, False)
|
|
6998
7105
|
logger.info(
|
|
6999
7106
|
"Thinking disabled (always=%s tool_turns=%s)",
|
|
7000
7107
|
PROXY_DISABLE_THINKING_ALWAYS,
|
|
@@ -9356,10 +9463,7 @@ async def _apply_empty_maxtokens_recovery(
|
|
|
9356
9463
|
if not _is_empty_maxtokens_response(openai_resp):
|
|
9357
9464
|
return openai_resp
|
|
9358
9465
|
retry_body = dict(openai_body)
|
|
9359
|
-
retry_body
|
|
9360
|
-
ctk = dict(retry_body.get("chat_template_kwargs") or {})
|
|
9361
|
-
ctk["enable_thinking"] = False
|
|
9362
|
-
retry_body["chat_template_kwargs"] = ctk
|
|
9466
|
+
_set_thinking(retry_body, False)
|
|
9363
9467
|
requested = int(openai_body.get("max_tokens") or PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
|
|
9364
9468
|
retry_body["max_tokens"] = min(requested, PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
|
|
9365
9469
|
logger.warning(
|
|
@@ -9525,7 +9629,7 @@ def _build_malformed_retry_body(
|
|
|
9525
9629
|
)
|
|
9526
9630
|
|
|
9527
9631
|
if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
|
|
9528
|
-
retry_body
|
|
9632
|
+
_set_thinking(retry_body, False)
|
|
9529
9633
|
|
|
9530
9634
|
# Option 3: Proactively strip grammar from retry when tools are present and
|
|
9531
9635
|
# grammar+tools is known to be incompatible. Prevents the 400 error
|
|
@@ -11485,7 +11589,7 @@ async def count_tokens(request: Request):
|
|
|
11485
11589
|
"fires at ~%d real tokens, before the pruner",
|
|
11486
11590
|
scale,
|
|
11487
11591
|
PROXY_CLIENT_ASSUMED_WINDOW,
|
|
11488
|
-
|
|
11592
|
+
_effective_context_window(),
|
|
11489
11593
|
int(PROXY_CLIENT_ASSUMED_WINDOW * 0.925 / scale),
|
|
11490
11594
|
)
|
|
11491
11595
|
return {"input_tokens": scaled}
|
|
@@ -11510,7 +11614,7 @@ def _count_tokens_scale() -> float:
|
|
|
11510
11614
|
return max(1.0, float(raw))
|
|
11511
11615
|
except ValueError:
|
|
11512
11616
|
return 1.0
|
|
11513
|
-
window =
|
|
11617
|
+
window = _effective_context_window()
|
|
11514
11618
|
if window <= 0:
|
|
11515
11619
|
return 1.0
|
|
11516
11620
|
frac = (
|
|
@@ -12845,9 +12949,10 @@ def _model_entry(model_id: str) -> dict:
|
|
|
12845
12949
|
nothing and leave the client on its own defaults.
|
|
12846
12950
|
"""
|
|
12847
12951
|
entry = {"id": model_id, "object": "model"}
|
|
12848
|
-
|
|
12952
|
+
window = _effective_context_window() if _context_window_measured else 0
|
|
12953
|
+
if window > 0 and not _should_passthrough_model(model_id):
|
|
12849
12954
|
for key in _CONTEXT_WINDOW_KEYS:
|
|
12850
|
-
entry[key] =
|
|
12955
|
+
entry[key] = window
|
|
12851
12956
|
return entry
|
|
12852
12957
|
|
|
12853
12958
|
|
|
@@ -12867,6 +12972,12 @@ async def models():
|
|
|
12867
12972
|
ANTHROPIC_PASSTHROUGH_MODELS=__local_only__ is set, all IDs (including
|
|
12868
12973
|
the Claude ones below) are served by the local llama.cpp backend.
|
|
12869
12974
|
"""
|
|
12975
|
+
# Refresh the rail before answering. This endpoint is the FIRST thing SDK
|
|
12976
|
+
# clients call (it is in _PROXY_AUTH_OPEN_PATHS precisely so discovery
|
|
12977
|
+
# works), and clients cache the model list — so answering from a window
|
|
12978
|
+
# that only refreshes on /v1/messages means the number a client keeps for
|
|
12979
|
+
# the whole session is the one from before any traffic existed.
|
|
12980
|
+
await _maybe_recheck_context_window()
|
|
12870
12981
|
return {"data": [_model_entry(mid) for mid in ADVERTISED_MODEL_IDS]}
|
|
12871
12982
|
|
|
12872
12983
|
|
|
@@ -2043,6 +2043,17 @@ class TestToolTurnControls(unittest.TestCase):
|
|
|
2043
2043
|
)
|
|
2044
2044
|
self.assertIn("enable_thinking", openai)
|
|
2045
2045
|
self.assertFalse(openai["enable_thinking"])
|
|
2046
|
+
# THE assertion whose absence let the switch be a no-op for months:
|
|
2047
|
+
# a `--jinja` server reads chat_template_kwargs and ignores the
|
|
2048
|
+
# top-level field entirely, so asserting only the line above passes
|
|
2049
|
+
# while the upstream keeps reasoning. Measured on the live server:
|
|
2050
|
+
# top-level only -> 703 chars of reasoning; this key -> 0.
|
|
2051
|
+
self.assertIs(
|
|
2052
|
+
openai.get("chat_template_kwargs", {}).get("enable_thinking"),
|
|
2053
|
+
False,
|
|
2054
|
+
"tool-turn thinking switch must reach chat_template_kwargs, "
|
|
2055
|
+
"or a --jinja upstream ignores it",
|
|
2056
|
+
)
|
|
2046
2057
|
finally:
|
|
2047
2058
|
setattr(proxy, "PROXY_DISABLE_THINKING_ON_TOOL_TURNS", old_disable)
|
|
2048
2059
|
|
|
@@ -11,6 +11,7 @@ window at all.
|
|
|
11
11
|
|
|
12
12
|
A client that cannot discover the window cannot size its history to it.
|
|
13
13
|
"""
|
|
14
|
+
import asyncio
|
|
14
15
|
import importlib.util
|
|
15
16
|
import os
|
|
16
17
|
import unittest
|
|
@@ -19,9 +20,18 @@ from pathlib import Path
|
|
|
19
20
|
proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def load_proxy(window="130048", passthrough=None):
|
|
23
|
-
"""Import a fresh proxy module under the given env (constants bind at import).
|
|
23
|
+
def load_proxy(window="130048", passthrough=None, measured=True):
|
|
24
|
+
"""Import a fresh proxy module under the given env (constants bind at import).
|
|
25
|
+
|
|
26
|
+
`measured` mirrors what a real startup leaves behind: True once /slots (or
|
|
27
|
+
an explicit setting) supplied the window, False when it is the hardcoded
|
|
28
|
+
guess. /v1/models only publishes a measured window.
|
|
29
|
+
"""
|
|
24
30
|
os.environ["PROXY_CONTEXT_WINDOW"] = window
|
|
31
|
+
# Pin the forcing scale: `auto` is the default, but a developer shell or a
|
|
32
|
+
# systemd unit exporting a fixed value would silently change what
|
|
33
|
+
# _count_tokens_scale returns and fail these tests for unrelated reasons.
|
|
34
|
+
os.environ["PROXY_COUNT_TOKENS_SCALE"] = "auto"
|
|
25
35
|
if passthrough is None:
|
|
26
36
|
os.environ.pop("ANTHROPIC_PASSTHROUGH_MODELS", None)
|
|
27
37
|
else:
|
|
@@ -29,6 +39,7 @@ def load_proxy(window="130048", passthrough=None):
|
|
|
29
39
|
spec = importlib.util.spec_from_file_location("anthropic_proxy_ctx", proxy_path)
|
|
30
40
|
mod = importlib.util.module_from_spec(spec)
|
|
31
41
|
spec.loader.exec_module(mod)
|
|
42
|
+
mod._context_window_measured = measured
|
|
32
43
|
return mod
|
|
33
44
|
|
|
34
45
|
|
|
@@ -36,6 +47,7 @@ class ModelsAdvertiseContextWindowTest(unittest.TestCase):
|
|
|
36
47
|
def tearDown(self):
|
|
37
48
|
os.environ.pop("PROXY_CONTEXT_WINDOW", None)
|
|
38
49
|
os.environ.pop("ANTHROPIC_PASSTHROUGH_MODELS", None)
|
|
50
|
+
os.environ.pop("PROXY_COUNT_TOKENS_SCALE", None)
|
|
39
51
|
|
|
40
52
|
def test_local_model_carries_the_window(self):
|
|
41
53
|
ap = load_proxy()
|
|
@@ -57,21 +69,77 @@ class ModelsAdvertiseContextWindowTest(unittest.TestCase):
|
|
|
57
69
|
# Stamping the local llama.cpp figure on it would make clients truncate
|
|
58
70
|
# needlessly — worse than the bug being fixed.
|
|
59
71
|
ap = load_proxy(passthrough=None) # default patterns: Claude passes through
|
|
72
|
+
ap.default_context_window = 199680 # exercise the DETECTED arm, not the fallback
|
|
60
73
|
for mid in ("claude-sonnet-4-6", "claude-haiku-4-5-20251001"):
|
|
61
74
|
self.assertNotIn("context_length", ap._model_entry(mid), mid)
|
|
62
75
|
|
|
63
76
|
def test_local_only_sentinel_means_every_id_is_local(self):
|
|
64
77
|
ap = load_proxy(passthrough="__local_only__")
|
|
78
|
+
ap.default_context_window = 199680
|
|
65
79
|
for mid in ap.ADVERTISED_MODEL_IDS:
|
|
66
|
-
self.assertEqual(ap._model_entry(mid).get("context_length"),
|
|
80
|
+
self.assertEqual(ap._model_entry(mid).get("context_length"), 199680, mid)
|
|
67
81
|
|
|
68
|
-
def
|
|
69
|
-
# Better to say nothing than to assert a wrong number.
|
|
70
|
-
|
|
82
|
+
def test_unknown_window_advertises_nothing(self):
|
|
83
|
+
# Better to say nothing than to assert a wrong number. "Unknown" means
|
|
84
|
+
# BOTH: no setting and no detected rail.
|
|
85
|
+
ap = load_proxy(window="0", measured=False)
|
|
86
|
+
ap.default_context_window = 0
|
|
71
87
|
self.assertEqual(
|
|
72
88
|
ap._model_entry("qwen36-35b-a3b-iq4xs"), {"id": "qwen36-35b-a3b-iq4xs", "object": "model"}
|
|
73
89
|
)
|
|
74
90
|
|
|
91
|
+
def test_a_guessed_window_is_never_published_as_fact(self):
|
|
92
|
+
# detect_context_window falls back to a hardcoded 131072 when the
|
|
93
|
+
# upstream is unreachable. The pruner may use that as a backstop, but
|
|
94
|
+
# advertising it would state a number nobody measured — the very
|
|
95
|
+
# failure this endpoint exists to prevent. Boot with llama down.
|
|
96
|
+
ap = load_proxy(window="0", measured=False)
|
|
97
|
+
ap.default_context_window = 131072
|
|
98
|
+
self.assertEqual(
|
|
99
|
+
ap._model_entry("qwen36-35b-a3b-iq4xs"), {"id": "qwen36-35b-a3b-iq4xs", "object": "model"}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_advertises_the_detected_rail_when_no_pin_is_set(self):
|
|
103
|
+
# Detection (from /slots) is the normal case: PROXY_CONTEXT_WINDOW=0 is
|
|
104
|
+
# what tells the launcher to auto-detect. Advertising nothing there left
|
|
105
|
+
# exactly the clients this endpoint exists for — the ones that size
|
|
106
|
+
# themselves from it — back on their own defaults.
|
|
107
|
+
ap = load_proxy(window="0")
|
|
108
|
+
ap.default_context_window = 199680
|
|
109
|
+
self.assertEqual(ap._model_entry("qwen36-35b-a3b-iq4xs")["context_length"], 199680)
|
|
110
|
+
|
|
111
|
+
def test_detected_rail_wins_over_a_stale_pin(self):
|
|
112
|
+
# THE BUG (live, 2026-08-16): the pin said 65,536 while the proxy
|
|
113
|
+
# enforced the detected 199,680. Clients were told the small number and
|
|
114
|
+
# compacted against it; the pruner guarded the large one. Whatever the
|
|
115
|
+
# number is, the advertised and enforced windows must be the same one.
|
|
116
|
+
ap = load_proxy(window="65536")
|
|
117
|
+
ap.default_context_window = 199680
|
|
118
|
+
self.assertEqual(ap._model_entry("qwen36-35b-a3b-iq4xs")["context_length"], 199680)
|
|
119
|
+
self.assertEqual(ap._effective_context_window(), 199680)
|
|
120
|
+
|
|
121
|
+
def test_pin_is_the_fallback_until_detection_lands(self):
|
|
122
|
+
# At startup, before the first /slots probe, the pin is all there is.
|
|
123
|
+
ap = load_proxy(window="65536")
|
|
124
|
+
ap.default_context_window = 0
|
|
125
|
+
self.assertEqual(ap._model_entry("qwen36-35b-a3b-iq4xs")["context_length"], 65536)
|
|
126
|
+
self.assertEqual(ap._effective_context_window(), 65536)
|
|
127
|
+
|
|
128
|
+
def test_advertised_window_equals_the_forcing_scale_denominator(self):
|
|
129
|
+
# The compaction-forcing scale and the advertisement are two halves of
|
|
130
|
+
# one contract: the client is told a window AND handed counts scaled to
|
|
131
|
+
# make it compact inside that window. Derived from different numbers,
|
|
132
|
+
# they fight — the 65,536/199,680 split had clients compacting at ~18%
|
|
133
|
+
# of the rail.
|
|
134
|
+
ap = load_proxy(window="65536")
|
|
135
|
+
ap.default_context_window = 199680
|
|
136
|
+
advertised = ap._model_entry("qwen36-35b-a3b-iq4xs")["context_length"]
|
|
137
|
+
frac = ap.PROXY_COMPACT_TARGET_FRACTION
|
|
138
|
+
if not (0 < frac < 1):
|
|
139
|
+
frac = min(0.9, ap.PROXY_CONTEXT_PRUNE_THRESHOLD * 0.95)
|
|
140
|
+
expected = ap.PROXY_CLIENT_ASSUMED_WINDOW / (advertised * frac)
|
|
141
|
+
self.assertAlmostEqual(ap._count_tokens_scale(), max(1.0, expected), places=6)
|
|
142
|
+
|
|
75
143
|
def test_entry_always_keeps_the_openai_shape(self):
|
|
76
144
|
ap = load_proxy()
|
|
77
145
|
for mid in ap.ADVERTISED_MODEL_IDS:
|
|
@@ -80,5 +148,54 @@ class ModelsAdvertiseContextWindowTest(unittest.TestCase):
|
|
|
80
148
|
self.assertEqual(e["object"], "model")
|
|
81
149
|
|
|
82
150
|
|
|
151
|
+
class DetectContextWindowTest(unittest.TestCase):
|
|
152
|
+
"""Startup resolution: ask the server first, settings are the fallback."""
|
|
153
|
+
|
|
154
|
+
def tearDown(self):
|
|
155
|
+
os.environ.pop("PROXY_CONTEXT_WINDOW", None)
|
|
156
|
+
os.environ.pop("PROXY_COUNT_TOKENS_SCALE", None)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _client(n_ctx=None, fail=False):
|
|
160
|
+
class _Resp:
|
|
161
|
+
status_code = 200
|
|
162
|
+
|
|
163
|
+
def json(self):
|
|
164
|
+
return [{"n_ctx": n_ctx}, {"n_ctx": n_ctx}]
|
|
165
|
+
|
|
166
|
+
class _Client:
|
|
167
|
+
async def get(self, url, timeout=None):
|
|
168
|
+
if fail:
|
|
169
|
+
raise RuntimeError("connection refused")
|
|
170
|
+
return _Resp()
|
|
171
|
+
|
|
172
|
+
return _Client()
|
|
173
|
+
|
|
174
|
+
def test_probes_the_server_even_when_a_fallback_is_configured(self):
|
|
175
|
+
# Previously the configured value short-circuited the probe, so a stale
|
|
176
|
+
# setting governed /v1/models until the first /v1/messages — and SDK
|
|
177
|
+
# clients read /v1/models before sending anything.
|
|
178
|
+
ap = load_proxy(window="65536", measured=False)
|
|
179
|
+
w = asyncio.run(ap.detect_context_window(self._client(n_ctx=199680)))
|
|
180
|
+
self.assertEqual(w, 199680)
|
|
181
|
+
self.assertTrue(ap._context_window_measured)
|
|
182
|
+
|
|
183
|
+
def test_falls_back_to_the_configured_value_when_the_probe_fails(self):
|
|
184
|
+
ap = load_proxy(window="65536", measured=False)
|
|
185
|
+
w = asyncio.run(ap.detect_context_window(self._client(fail=True)))
|
|
186
|
+
self.assertEqual(w, 65536)
|
|
187
|
+
# An operator setting is an assertion, so it may be published.
|
|
188
|
+
self.assertTrue(ap._context_window_measured)
|
|
189
|
+
|
|
190
|
+
def test_last_resort_guess_is_marked_unmeasured(self):
|
|
191
|
+
ap = load_proxy(window="0", measured=True)
|
|
192
|
+
w = asyncio.run(ap.detect_context_window(self._client(fail=True)))
|
|
193
|
+
self.assertEqual(w, 131072)
|
|
194
|
+
self.assertFalse(ap._context_window_measured)
|
|
195
|
+
self.assertEqual(
|
|
196
|
+
ap._model_entry("qwen36-35b-a3b-iq4xs"), {"id": "qwen36-35b-a3b-iq4xs", "object": "model"}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
83
200
|
if __name__ == "__main__":
|
|
84
201
|
unittest.main()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Turning reasoning off must reach the place llama.cpp actually reads it.
|
|
2
|
+
|
|
3
|
+
Measured on the live server (Qwen3.6-35B-A3B, launched `--jinja
|
|
4
|
+
--chat-template-kwargs {"enable_thinking": true}`), identical tool-call prompt:
|
|
5
|
+
|
|
6
|
+
top-level "enable_thinking": false -> 703 chars of reasoning, 196 tokens
|
|
7
|
+
chat_template_kwargs.enable_thinking -> 0 chars, 27 tokens
|
|
8
|
+
|
|
9
|
+
and on a planning-flavoured prompt with max_tokens=700, reasoning consumed the
|
|
10
|
+
ENTIRE budget and NO tool call was emitted — the "stuck planning" symptom.
|
|
11
|
+
|
|
12
|
+
Every thinking switch in the proxy set only the top-level field, so
|
|
13
|
+
PROXY_DISABLE_THINKING_ALWAYS, PROXY_DISABLE_THINKING_ON_TOOL_TURNS and the
|
|
14
|
+
Anthropic `thinking: {type: disabled}` parameter were all no-ops against a jinja
|
|
15
|
+
server: the controls existed, logged themselves as active, and changed nothing.
|
|
16
|
+
"""
|
|
17
|
+
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import unittest
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_PRIOR_AUTOLOAD = os.environ.get("UAP_PROXY_ENV_AUTOLOAD")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tearDownModule():
|
|
29
|
+
# Restore rather than leak into any test sharing this process.
|
|
30
|
+
if _PRIOR_AUTOLOAD is None:
|
|
31
|
+
os.environ.pop("UAP_PROXY_ENV_AUTOLOAD", None)
|
|
32
|
+
else:
|
|
33
|
+
os.environ["UAP_PROXY_ENV_AUTOLOAD"] = _PRIOR_AUTOLOAD
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_proxy(**env):
|
|
37
|
+
# Explicit, so a project .uap/proxy.env cannot leak in when this module is
|
|
38
|
+
# run directly rather than through the npm script.
|
|
39
|
+
os.environ["UAP_PROXY_ENV_AUTOLOAD"] = "0"
|
|
40
|
+
for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
|
|
41
|
+
os.environ.pop(k, None)
|
|
42
|
+
os.environ.update({k: v for k, v in env.items()})
|
|
43
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_thinking", proxy_path)
|
|
44
|
+
mod = importlib.util.module_from_spec(spec)
|
|
45
|
+
spec.loader.exec_module(mod)
|
|
46
|
+
return mod
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SetThinkingTest(unittest.TestCase):
|
|
50
|
+
def tearDown(self):
|
|
51
|
+
for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
|
|
52
|
+
os.environ.pop(k, None)
|
|
53
|
+
|
|
54
|
+
def test_disabling_reaches_chat_template_kwargs(self):
|
|
55
|
+
# THE BUG: the top-level field alone is overridden by the server's
|
|
56
|
+
# --chat-template-kwargs default, so reasoning kept flowing.
|
|
57
|
+
ap = load_proxy()
|
|
58
|
+
body = {}
|
|
59
|
+
ap._set_thinking(body, False)
|
|
60
|
+
self.assertIs(body["chat_template_kwargs"]["enable_thinking"], False)
|
|
61
|
+
|
|
62
|
+
def test_top_level_is_still_set_for_servers_that_read_it(self):
|
|
63
|
+
# Belt and braces: non-jinja / older builds read the top-level field.
|
|
64
|
+
ap = load_proxy()
|
|
65
|
+
body = {}
|
|
66
|
+
ap._set_thinking(body, False)
|
|
67
|
+
self.assertIs(body["enable_thinking"], False)
|
|
68
|
+
|
|
69
|
+
def test_enabling_reaches_both_places_too(self):
|
|
70
|
+
ap = load_proxy()
|
|
71
|
+
body = {}
|
|
72
|
+
ap._set_thinking(body, True)
|
|
73
|
+
self.assertIs(body["enable_thinking"], True)
|
|
74
|
+
self.assertIs(body["chat_template_kwargs"]["enable_thinking"], True)
|
|
75
|
+
|
|
76
|
+
def test_existing_chat_template_kwargs_are_preserved(self):
|
|
77
|
+
# The server is launched with other template kwargs; clobbering the dict
|
|
78
|
+
# would drop them.
|
|
79
|
+
ap = load_proxy()
|
|
80
|
+
body = {"chat_template_kwargs": {"preserve_thinking": False, "custom": 1}}
|
|
81
|
+
ap._set_thinking(body, False)
|
|
82
|
+
self.assertEqual(body["chat_template_kwargs"]["custom"], 1)
|
|
83
|
+
self.assertIs(body["chat_template_kwargs"]["preserve_thinking"], False)
|
|
84
|
+
self.assertIs(body["chat_template_kwargs"]["enable_thinking"], False)
|
|
85
|
+
|
|
86
|
+
def test_a_non_dict_kwargs_value_does_not_raise(self):
|
|
87
|
+
# Defensive: a malformed client body must not 500 the turn.
|
|
88
|
+
ap = load_proxy()
|
|
89
|
+
body = {"chat_template_kwargs": "nonsense"}
|
|
90
|
+
ap._set_thinking(body, False)
|
|
91
|
+
self.assertIs(body["enable_thinking"], False)
|
|
92
|
+
|
|
93
|
+
def test_a_shallow_retry_copy_does_not_disable_thinking_on_the_original(self):
|
|
94
|
+
# The helper's whole rationale: retry bodies are built as
|
|
95
|
+
# `dict(openai_body)`, which SHARES this nested dict. Mutating it in
|
|
96
|
+
# place would disable thinking on the in-flight request too.
|
|
97
|
+
ap = load_proxy()
|
|
98
|
+
original = {"chat_template_kwargs": {"enable_thinking": True}}
|
|
99
|
+
retry = dict(original)
|
|
100
|
+
ap._set_thinking(retry, False)
|
|
101
|
+
self.assertIs(original["chat_template_kwargs"]["enable_thinking"], True)
|
|
102
|
+
self.assertIs(retry["chat_template_kwargs"]["enable_thinking"], False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SwitchesReachTheTemplateTest(unittest.TestCase):
|
|
106
|
+
"""The switches, exercised through the real request builder."""
|
|
107
|
+
|
|
108
|
+
def tearDown(self):
|
|
109
|
+
for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
|
|
110
|
+
os.environ.pop(k, None)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _build(ap, tools=False):
|
|
114
|
+
body = {"model": "test", "messages": [{"role": "user", "content": "hi"}]}
|
|
115
|
+
if tools:
|
|
116
|
+
body["tools"] = [
|
|
117
|
+
{"name": "Read", "description": "Read file", "input_schema": {"type": "object"}}
|
|
118
|
+
]
|
|
119
|
+
return ap.build_openai_request(body, ap.SessionMonitor(context_window=262144))
|
|
120
|
+
|
|
121
|
+
def test_always_switch_reaches_chat_template_kwargs(self):
|
|
122
|
+
ap = load_proxy(PROXY_DISABLE_THINKING_ALWAYS="on")
|
|
123
|
+
built = self._build(ap)
|
|
124
|
+
self.assertIs(built["chat_template_kwargs"]["enable_thinking"], False)
|
|
125
|
+
|
|
126
|
+
def test_protocol_default_does_NOT_force_the_template(self):
|
|
127
|
+
# Deliberate: Anthropic defaults thinking off, and making that
|
|
128
|
+
# authoritative would flip every client that never asked for it from
|
|
129
|
+
# the server's configured default to off. A serving-policy change is
|
|
130
|
+
# not a bug fix; operators have an explicit switch for that.
|
|
131
|
+
ap = load_proxy()
|
|
132
|
+
built = self._build(ap)
|
|
133
|
+
self.assertIs(built["enable_thinking"], False)
|
|
134
|
+
self.assertNotIn(
|
|
135
|
+
"enable_thinking",
|
|
136
|
+
built.get("chat_template_kwargs", {}),
|
|
137
|
+
"the protocol default must not override the server's configured default",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def test_a_client_cannot_turn_thinking_ON_against_the_server_default(self):
|
|
141
|
+
# The measured bug was one-directional (an OFF switch that didn't
|
|
142
|
+
# stick); the fix stays one-directional.
|
|
143
|
+
ap = load_proxy()
|
|
144
|
+
body = {
|
|
145
|
+
"model": "test",
|
|
146
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
147
|
+
"thinking": {"type": "enabled"},
|
|
148
|
+
}
|
|
149
|
+
built = ap.build_openai_request(body, ap.SessionMonitor(context_window=262144))
|
|
150
|
+
self.assertIs(built["enable_thinking"], True)
|
|
151
|
+
self.assertNotIn("enable_thinking", built.get("chat_template_kwargs", {}))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
unittest.main()
|