@miller-tech/uap 1.220.10 → 1.220.12
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/config/llama-profiles/gemma4-26b-a4b-mtp.env +9 -0
- package/config/llama-profiles/qwen36-35b-a3b.env +9 -0
- package/config/llama-profiles/qwen38-27b-dflash2.env +9 -0
- package/config/llama-profiles/qwen38-27b-mtp.env +9 -0
- 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 +191 -7
- package/tools/agents/tests/test_finalize_suppression.py +14 -4
- package/tools/agents/tests/test_stuck_break_hard.py +328 -0
- package/tools/agents/tests/test_upstream_model_aliases.py +116 -0
|
@@ -41,6 +41,15 @@
|
|
|
41
41
|
# =====================================================================
|
|
42
42
|
LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/latest-master/build/bin/llama-server
|
|
43
43
|
LLAMA_MODEL=/home/cogtek/Downloads/gemma-4-26B-A4B-it-UD-IQ4_XS.gguf
|
|
44
|
+
# Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
|
|
45
|
+
# here: the script's fallback derives a name from the GGUF filename, which is a
|
|
46
|
+
# name rather than a path but is not the name client configs are written
|
|
47
|
+
# against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
|
|
48
|
+
# rewrite the model on every request.
|
|
49
|
+
# ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
|
|
50
|
+
# *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
|
|
51
|
+
# Keep the id clients actually use sorting first if you add more.
|
|
52
|
+
LLAMA_ALIAS=Gemma4-26B-A4B
|
|
44
53
|
LLAMA_DRAFT_MODEL=/home/cogtek/Downloads/mtp-gemma-4-26B-A4B-it-Q8_0.gguf
|
|
45
54
|
|
|
46
55
|
# Vision projector MUST be pinned. The launch script's auto-discovery falls
|
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
# =====================================================================
|
|
24
24
|
LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/latest-master/build/bin/llama-server
|
|
25
25
|
LLAMA_MODEL=/home/cogtek/Downloads/Qwen3.6-35B-A3B-UD-IQ4_XS.gguf
|
|
26
|
+
# Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
|
|
27
|
+
# here: the script's fallback derives a name from the GGUF filename, which is a
|
|
28
|
+
# name rather than a path but is not the name client configs are written
|
|
29
|
+
# against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
|
|
30
|
+
# rewrite the model on every request.
|
|
31
|
+
# ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
|
|
32
|
+
# *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
|
|
33
|
+
# Keep the id clients actually use sorting first if you add more.
|
|
34
|
+
LLAMA_ALIAS=Qwen3.6-35B-A3B
|
|
26
35
|
LLAMA_MMPROJ=/home/cogtek/Downloads/mmproj-F16.gguf
|
|
27
36
|
|
|
28
37
|
LLAMA_HOST=0.0.0.0
|
|
@@ -68,6 +68,15 @@
|
|
|
68
68
|
# =====================================================================
|
|
69
69
|
LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/dflash2-trial/build-cuda/bin/llama-server
|
|
70
70
|
LLAMA_MODEL=/home/cogtek/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf
|
|
71
|
+
# Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
|
|
72
|
+
# here: the script's fallback derives a name from the GGUF filename, which is a
|
|
73
|
+
# name rather than a path but is not the name client configs are written
|
|
74
|
+
# against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
|
|
75
|
+
# rewrite the model on every request.
|
|
76
|
+
# ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
|
|
77
|
+
# *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
|
|
78
|
+
# Keep the id clients actually use sorting first if you add more.
|
|
79
|
+
LLAMA_ALIAS=Qwen3.8-27B
|
|
71
80
|
# CONFIRMED INCOMPATIBLE 2026-08-20: mmproj-BF16.gguf crashed the server on
|
|
72
81
|
# load -- "mismatch between text model (n_embd = 5120) and mmproj
|
|
73
82
|
# (n_embd = 2048)". It was built for a different, smaller model's vision
|
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
# =====================================================================
|
|
37
37
|
LLAMA_BIN=/home/cogtek/llama.cpp/.worktrees/dflash-mtp-upgrade/build-cuda/bin/llama-server
|
|
38
38
|
LLAMA_MODEL=/home/cogtek/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf
|
|
39
|
+
# Model id advertised to API clients (llama-server --alias). Set it EXPLICITLY
|
|
40
|
+
# here: the script's fallback derives a name from the GGUF filename, which is a
|
|
41
|
+
# name rather than a path but is not the name client configs are written
|
|
42
|
+
# against ("Qwen3.8-27B-UD-IQ4_XS" vs "Qwen3.8-27B"), so the proxy would still
|
|
43
|
+
# rewrite the model on every request.
|
|
44
|
+
# ORDER IS NOT PRESERVED: llama.cpp stores aliases in a std::set and reports
|
|
45
|
+
# *begin() as the model id, so the ASCII-FIRST entry becomes the primary id.
|
|
46
|
+
# Keep the id clients actually use sorting first if you add more.
|
|
47
|
+
LLAMA_ALIAS=Qwen3.8-27B
|
|
39
48
|
# CONFIRMED INCOMPATIBLE 2026-08-20: mmproj-BF16.gguf crashed the server on
|
|
40
49
|
# load -- "mismatch between text model (n_embd = 5120) and mmproj
|
|
41
50
|
# (n_embd = 2048)". It was built for a different, smaller model's vision
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.220.
|
|
3
|
+
"version": "1.220.12",
|
|
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_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline 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_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_rtk_wrap 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_stuck_break_hard 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 tools.agents.tests.test_cycle_break_wait_poll",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline 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_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_rtk_wrap 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_stuck_break_hard 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 tools.agents.tests.test_cycle_break_wait_poll tools.agents.tests.test_upstream_model_aliases",
|
|
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
|
|
@@ -1342,7 +1342,20 @@ async def _upstream_model_ids_cached() -> list[str] | None:
|
|
|
1342
1342
|
if r.status_code != 200:
|
|
1343
1343
|
return None
|
|
1344
1344
|
data = (r.json() or {}).get("data") or []
|
|
1345
|
-
|
|
1345
|
+
# Aliases count as served ids. llama-server reports ONE primary `id`
|
|
1346
|
+
# (the ASCII-first entry of a std::set) plus an `aliases` array, so a
|
|
1347
|
+
# config naming any other registered alias looked unserved and got
|
|
1348
|
+
# rewritten on every request -- which is most of what --alias was added
|
|
1349
|
+
# to stop. Reading only `id` made the flag half-useless.
|
|
1350
|
+
ids: list[str] = []
|
|
1351
|
+
for m in data:
|
|
1352
|
+
if not isinstance(m, dict):
|
|
1353
|
+
continue
|
|
1354
|
+
if m.get("id"):
|
|
1355
|
+
ids.append(str(m["id"]))
|
|
1356
|
+
for alias in m.get("aliases") or []:
|
|
1357
|
+
if alias and str(alias) not in ids:
|
|
1358
|
+
ids.append(str(alias))
|
|
1346
1359
|
if ids:
|
|
1347
1360
|
_upstream_model_ids = ids
|
|
1348
1361
|
return ids or None
|
|
@@ -4983,6 +4996,33 @@ def _has_tool_definitions(anthropic_body: dict) -> bool:
|
|
|
4983
4996
|
return isinstance(tools, list) and len(tools) > 0
|
|
4984
4997
|
|
|
4985
4998
|
|
|
4999
|
+
def _should_buffer_turn(
|
|
5000
|
+
is_stream: bool,
|
|
5001
|
+
body: dict,
|
|
5002
|
+
openai_body: dict,
|
|
5003
|
+
monitor: "SessionMonitor",
|
|
5004
|
+
) -> bool:
|
|
5005
|
+
"""Whether this turn must be collected in full before anything is sent.
|
|
5006
|
+
|
|
5007
|
+
Wraps _should_use_guarded_non_stream with one extra reason: a turn whose
|
|
5008
|
+
tools were deliberately stripped.
|
|
5009
|
+
|
|
5010
|
+
That turn CANNOT be streamed. The stripping is what makes it terminal, and
|
|
5011
|
+
the sanitiser that scrubs tool-call markup out of the reply only runs on the
|
|
5012
|
+
buffered path -- a streamed turn is already on the wire before anything can
|
|
5013
|
+
inspect it. Worse, the hard tier pops `tool_choice`, which is precisely the
|
|
5014
|
+
key _should_use_guarded_non_stream requires, so stripping the tools was
|
|
5015
|
+
itself enough to route the turn AWAY from the guarded path. Measured
|
|
5016
|
+
2026-08-25: a hard-tier streaming turn delivered the raw markup verbatim to
|
|
5017
|
+
opencode, which rendered it and ended the session -- the incident this rail
|
|
5018
|
+
exists to end. Buffering costs nothing here: the turn is terminal by
|
|
5019
|
+
construction, so there is no long generation for the client to watch.
|
|
5020
|
+
"""
|
|
5021
|
+
if monitor.suppress_text_tool_extraction:
|
|
5022
|
+
return True
|
|
5023
|
+
return _should_use_guarded_non_stream(is_stream, body, openai_body)
|
|
5024
|
+
|
|
5025
|
+
|
|
4986
5026
|
def _should_use_guarded_non_stream(
|
|
4987
5027
|
is_stream: bool,
|
|
4988
5028
|
anthropic_body: dict,
|
|
@@ -8723,6 +8763,84 @@ def _strip_residual_tool_call_xml(text: str) -> str:
|
|
|
8723
8763
|
return cleaned
|
|
8724
8764
|
|
|
8725
8765
|
|
|
8766
|
+
# The prose a suppressed turn falls back to when stripping the markup leaves
|
|
8767
|
+
# nothing. An EMPTY assistant message is no better for the client than the raw
|
|
8768
|
+
# XML was: opencode ends the turn either way and the operator sees a blank
|
|
8769
|
+
# reply, so the breaker must say what it did.
|
|
8770
|
+
STUCK_BREAK_PROSE_FALLBACK = (
|
|
8771
|
+
"I repeated the same tool call without making progress, so tools were "
|
|
8772
|
+
"withheld for this turn. I have not run anything further. Tell me which "
|
|
8773
|
+
"different approach to take, or confirm the next step."
|
|
8774
|
+
)
|
|
8775
|
+
|
|
8776
|
+
|
|
8777
|
+
# Hermes blocks, for DELETION rather than parsing.
|
|
8778
|
+
#
|
|
8779
|
+
# Deliberately not _HERMES_FUNCTION_RE. That one ends with `(?:</function>|\Z)`
|
|
8780
|
+
# so it can salvage a premature-EOS tool call, which is right when parsing and
|
|
8781
|
+
# destructive when deleting: a reply that merely MENTIONS a tag loses everything
|
|
8782
|
+
# after it. Measured — "The proxy scans for <function=bash> and then deletes
|
|
8783
|
+
# everything after it. THIS SHOULD SURVIVE." collapsed to "The proxy scans for".
|
|
8784
|
+
# That is not hypothetical here: the hard-tier directive asks the model to say
|
|
8785
|
+
# "what the repeated call returned", i.e. it invites naming the call.
|
|
8786
|
+
#
|
|
8787
|
+
# So a block only counts as markup when it is closed, or when it contains a
|
|
8788
|
+
# parameter tag — both of which a prose mention will not have. The name class is
|
|
8789
|
+
# also wider than the parser's (`[^>\s]+`): the parser can afford to ignore a
|
|
8790
|
+
# dotted or hyphenated name it cannot map to a tool, but leaving that block in
|
|
8791
|
+
# the text is the leak.
|
|
8792
|
+
_SANITIZE_HERMES_BLOCK_RE = re.compile(
|
|
8793
|
+
r"<function=[^>\s]+>"
|
|
8794
|
+
# Either a properly closed block -- whose body may not contain another
|
|
8795
|
+
# opener, or one malformed block swallows the prose up to the NEXT block's
|
|
8796
|
+
# closing tag (measured: "IMPORTANT PROSE" between two blocks vanished) --
|
|
8797
|
+
r"(?:(?:(?!<function=).)*?</function>"
|
|
8798
|
+
# ...or an unclosed one that at least carries a parameter tag, which is what
|
|
8799
|
+
# separates a real premature-EOS emission from a prose mention.
|
|
8800
|
+
r"|(?:(?!<function=).)*?<parameter=(?:(?!<function=).)*?(?:</function>|\Z))",
|
|
8801
|
+
re.DOTALL,
|
|
8802
|
+
)
|
|
8803
|
+
# Gemma's DSL, with the premature-EOS arm the parsing regex lacks.
|
|
8804
|
+
_SANITIZE_GEMMA_DSL_RE = re.compile(
|
|
8805
|
+
r"<\|tool_call>.*?(?:<tool_call\|>|\Z)",
|
|
8806
|
+
re.DOTALL,
|
|
8807
|
+
)
|
|
8808
|
+
# Whatever the block patterns left behind: closing tags with no opener, and a
|
|
8809
|
+
# LONE opener. The lone opener is stripped as a tag rather than as a block on
|
|
8810
|
+
# purpose -- it is inert to every client, and the text around it is far more
|
|
8811
|
+
# likely to be the model explaining which call it kept repeating (which the
|
|
8812
|
+
# hard-tier directive asks for) than a real emission.
|
|
8813
|
+
_SANITIZE_ORPHAN_TAG_RE = re.compile(
|
|
8814
|
+
r"</?parameter(?:=[^>\s]+)?>|</?function(?:=[^>\s]+)?>|</?tool_call>|<tool_call\|>",
|
|
8815
|
+
)
|
|
8816
|
+
|
|
8817
|
+
|
|
8818
|
+
def _strip_all_tool_call_markup(text: str) -> str:
|
|
8819
|
+
"""Remove the tool-call markup forms a model emits INSTEAD of calling a tool.
|
|
8820
|
+
|
|
8821
|
+
`_strip_residual_tool_call_xml` only understands the ``<tool_call>``
|
|
8822
|
+
envelope. A model that emits a BARE Hermes ``<function=…>`` block (no
|
|
8823
|
+
envelope) or Gemma's DSL walks straight through it, which is how raw markup
|
|
8824
|
+
reached a client on a turn whose whole purpose was to end in prose. Applied
|
|
8825
|
+
on the suppressed path only — the normal path promotes this markup to real
|
|
8826
|
+
tool calls instead of deleting it.
|
|
8827
|
+
|
|
8828
|
+
Fenced ```json`` calls are deliberately NOT stripped: the normal path
|
|
8829
|
+
schema-matches them, but here they are indistinguishable from a model
|
|
8830
|
+
legitimately quoting JSON, and destroying real output is the worse error.
|
|
8831
|
+
"""
|
|
8832
|
+
cleaned = _strip_residual_tool_call_xml(text)
|
|
8833
|
+
if "<function=" in cleaned:
|
|
8834
|
+
cleaned = _SANITIZE_HERMES_BLOCK_RE.sub("", cleaned)
|
|
8835
|
+
if "<|tool_call>" in cleaned:
|
|
8836
|
+
cleaned = _SANITIZE_GEMMA_DSL_RE.sub("", cleaned)
|
|
8837
|
+
# Orphans left by a block whose closing tag never arrived, or by the
|
|
8838
|
+
# envelope strip above.
|
|
8839
|
+
cleaned = _SANITIZE_ORPHAN_TAG_RE.sub("", cleaned)
|
|
8840
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
8841
|
+
return cleaned
|
|
8842
|
+
|
|
8843
|
+
|
|
8726
8844
|
# Pattern: runaway closing braces like }}}}}
|
|
8727
8845
|
_GARBLED_RUNAWAY_BRACES_RE = re.compile(r"\}{4,}")
|
|
8728
8846
|
# Pattern: repetitive digit sequences like 000000 or 398859738398859738
|
|
@@ -10952,6 +11070,69 @@ def _maybe_apply_session_contamination_breaker(
|
|
|
10952
11070
|
# ===========================================================================
|
|
10953
11071
|
|
|
10954
11072
|
|
|
11073
|
+
# Anything carrying one of these is worth running the stripper over. Kept wider
|
|
11074
|
+
# than the shapes the PARSER understands: an orphan closing tag or a lone
|
|
11075
|
+
# parameter tag is still markup on the client's screen, and gating the stripper
|
|
11076
|
+
# on the parser's markers let those through untouched.
|
|
11077
|
+
_SUPPRESSED_MARKUP_MARKERS = (
|
|
11078
|
+
"<tool_call>",
|
|
11079
|
+
"</tool_call>",
|
|
11080
|
+
"<tool_call|>",
|
|
11081
|
+
"<function=",
|
|
11082
|
+
"</function>",
|
|
11083
|
+
"<parameter=",
|
|
11084
|
+
"</parameter>",
|
|
11085
|
+
"<|tool_call>",
|
|
11086
|
+
)
|
|
11087
|
+
|
|
11088
|
+
|
|
11089
|
+
def _sanitize_suppressed_tool_markup(openai_resp: dict) -> dict:
|
|
11090
|
+
"""Scrub tool-call markup from a turn whose tools were deliberately stripped.
|
|
11091
|
+
|
|
11092
|
+
Mutates *openai_resp* in place and returns it. A no-op unless a message
|
|
11093
|
+
is text-only and actually carries markup, so an ordinary prose reply on a
|
|
11094
|
+
suppressed turn passes through byte-for-byte.
|
|
11095
|
+
"""
|
|
11096
|
+
for choice in openai_resp.get("choices") or []:
|
|
11097
|
+
if not isinstance(choice, dict):
|
|
11098
|
+
continue
|
|
11099
|
+
message = choice.get("message")
|
|
11100
|
+
# A malformed upstream payload must not turn a degraded turn into a 500.
|
|
11101
|
+
if not isinstance(message, dict) or message.get("tool_calls"):
|
|
11102
|
+
continue
|
|
11103
|
+
# The reasoning sidecar is scrubbed too: when `content` is empty the
|
|
11104
|
+
# EMPTY-OUTPUT GUARD promotes reasoning into the VISIBLE text, so
|
|
11105
|
+
# leaving it alone just relocates the leak.
|
|
11106
|
+
for field in ("content", "reasoning_content", "reasoning"):
|
|
11107
|
+
text = message.get(field)
|
|
11108
|
+
if not isinstance(text, str) or not text:
|
|
11109
|
+
continue
|
|
11110
|
+
if not any(marker in text for marker in _SUPPRESSED_MARKUP_MARKERS):
|
|
11111
|
+
continue
|
|
11112
|
+
cleaned = _strip_all_tool_call_markup(text)
|
|
11113
|
+
if field == "content":
|
|
11114
|
+
# Emptiness is judged on what SURVIVES thinking extraction. A
|
|
11115
|
+
# reply of "<think></think>" is truthy but renders blank, and
|
|
11116
|
+
# blank is the outcome the fallback exists to prevent.
|
|
11117
|
+
_, visible = _extract_thinking_block(cleaned)
|
|
11118
|
+
if not visible.strip():
|
|
11119
|
+
cleaned = (
|
|
11120
|
+
f"{cleaned}\n\n{STUCK_BREAK_PROSE_FALLBACK}"
|
|
11121
|
+
if cleaned.strip()
|
|
11122
|
+
else STUCK_BREAK_PROSE_FALLBACK
|
|
11123
|
+
)
|
|
11124
|
+
message[field] = cleaned
|
|
11125
|
+
logger.warning(
|
|
11126
|
+
"SUPPRESSED TOOL MARKUP: scrubbed tool-call markup from %s on a "
|
|
11127
|
+
"tools-stripped turn (%d chars in, %d out) -- the turn now ends "
|
|
11128
|
+
"in prose as intended",
|
|
11129
|
+
field,
|
|
11130
|
+
len(text),
|
|
11131
|
+
len(cleaned),
|
|
11132
|
+
)
|
|
11133
|
+
return openai_resp
|
|
11134
|
+
|
|
11135
|
+
|
|
10955
11136
|
def _maybe_extract_text_tool_calls(
|
|
10956
11137
|
openai_resp: dict,
|
|
10957
11138
|
anthropic_tools: list[dict] | None = None,
|
|
@@ -10970,8 +11151,15 @@ def _maybe_extract_text_tool_calls(
|
|
|
10970
11151
|
# A hard finalize breaker stripped tools this turn to force a terminal
|
|
10971
11152
|
# text-only end_turn; do not resurrect prose tool-calls (that would defeat
|
|
10972
11153
|
# the breaker and continue the loop). Carried per-turn on the SessionMonitor.
|
|
11154
|
+
#
|
|
11155
|
+
# Not resurrecting is only half the job. Returning the response untouched
|
|
11156
|
+
# ships the raw markup to the client as the assistant's visible text --
|
|
11157
|
+
# measured live 2026-08-25 on opencode session ses_fc7a27ea…, which rendered
|
|
11158
|
+
# a <tool_call><function=bash>… block as the reply, logged "exiting loop",
|
|
11159
|
+
# and left the operator retyping "go" into the same loop. A turn forced to
|
|
11160
|
+
# end in prose must actually END IN PROSE.
|
|
10973
11161
|
if suppress:
|
|
10974
|
-
return openai_resp
|
|
11162
|
+
return _sanitize_suppressed_tool_markup(openai_resp)
|
|
10975
11163
|
choice = (openai_resp.get("choices") or [{}])[0]
|
|
10976
11164
|
message = choice.get("message", {})
|
|
10977
11165
|
|
|
@@ -12640,11 +12828,7 @@ async def messages(request: Request):
|
|
|
12640
12828
|
# Last step before the wire: names llama.cpp's grammar builder can parse.
|
|
12641
12829
|
_sanitize_tool_names_for_upstream(openai_body)
|
|
12642
12830
|
|
|
12643
|
-
use_guarded_non_stream =
|
|
12644
|
-
is_stream,
|
|
12645
|
-
body,
|
|
12646
|
-
openai_body,
|
|
12647
|
-
)
|
|
12831
|
+
use_guarded_non_stream = _should_buffer_turn(is_stream, body, openai_body, monitor)
|
|
12648
12832
|
if use_guarded_non_stream:
|
|
12649
12833
|
async def _produce_guarded():
|
|
12650
12834
|
strict_body = dict(openai_body)
|
|
@@ -99,14 +99,24 @@ class TestFinalizeSuppressionExtractor(unittest.TestCase):
|
|
|
99
99
|
self.assertEqual(msg["tool_calls"][0]["function"]["name"], "Bash")
|
|
100
100
|
self.assertEqual(resp["choices"][0]["finish_reason"], "tool_calls")
|
|
101
101
|
|
|
102
|
-
def
|
|
103
|
-
"""suppress=True:
|
|
104
|
-
|
|
102
|
+
def test_suppressed_ends_the_turn_in_clean_prose(self):
|
|
103
|
+
"""suppress=True: the client sees a clean end_turn with no action, so
|
|
104
|
+
the agentic loop terminates.
|
|
105
|
+
|
|
106
|
+
This used to assert the markup was still IN the text. That was an
|
|
107
|
+
incidental detail of the first implementation, not the contract this
|
|
108
|
+
docstring describes -- and it froze a real defect in place: the raw
|
|
109
|
+
block was shipped to the client as the assistant's visible reply.
|
|
110
|
+
Measured live 2026-08-25 (opencode ses_fc7a27ea...), which rendered the
|
|
111
|
+
markup, logged "exiting loop", and left the operator retyping "go".
|
|
112
|
+
The contract is: no resurrected call, finish_reason stop, and NO markup.
|
|
113
|
+
"""
|
|
105
114
|
resp = _prose_tool_resp()
|
|
106
115
|
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
107
116
|
msg = resp["choices"][0]["message"]
|
|
108
117
|
self.assertFalse(msg.get("tool_calls"), "must NOT resurrect tool call on finalize")
|
|
109
|
-
self.
|
|
118
|
+
self.assertNotIn(chr(60) + "function=", msg["content"])
|
|
119
|
+
self.assertTrue(msg["content"].strip(), "an empty reply is no better than markup")
|
|
110
120
|
self.assertEqual(resp["choices"][0]["finish_reason"], "stop")
|
|
111
121
|
|
|
112
122
|
def test_conversion_respects_suppression(self):
|
|
@@ -26,6 +26,10 @@ def _load_proxy_module():
|
|
|
26
26
|
|
|
27
27
|
proxy = _load_proxy_module()
|
|
28
28
|
|
|
29
|
+
# Angle brackets built at runtime; see _markup below for why.
|
|
30
|
+
LT = chr(60)
|
|
31
|
+
GT = chr(62)
|
|
32
|
+
|
|
29
33
|
|
|
30
34
|
def _looping_monitor(fires_so_far: int):
|
|
31
35
|
mon = proxy.SessionMonitor()
|
|
@@ -83,5 +87,329 @@ class TestStuckBreakHard(unittest.TestCase):
|
|
|
83
87
|
self.assertLessEqual(proxy.PROXY_STUCK_BREAK_HARD_FIRES, 5)
|
|
84
88
|
|
|
85
89
|
|
|
90
|
+
class TestSuppressedTurnEndsInProse(unittest.TestCase):
|
|
91
|
+
"""The response half of the hard break.
|
|
92
|
+
|
|
93
|
+
Suppressing XML resurrection stops the loop re-arming, but on its own it
|
|
94
|
+
ships the raw markup to the client as the assistant's visible text.
|
|
95
|
+
Measured live 2026-08-25 (opencode ses_fc7a27ea...): the client rendered a
|
|
96
|
+
<tool_call> block as the reply, logged "exiting loop", and the operator
|
|
97
|
+
retyped "go" straight back into the same loop. A turn forced to end in
|
|
98
|
+
prose must actually end in prose.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _markup(envelope=True, closed=True, lead=""):
|
|
103
|
+
# Built from chr() so the literal tags never appear in this file --
|
|
104
|
+
# the repo's bash-safety enforcer refuses commands carrying standalone
|
|
105
|
+
# tool-call tag lines, which makes a literal fixture unrunnable.
|
|
106
|
+
def tag(name):
|
|
107
|
+
return LT + name + GT
|
|
108
|
+
parts = [tag("function=bash"), tag("parameter=command"), "grep -rn X ."]
|
|
109
|
+
if closed:
|
|
110
|
+
parts += [tag("/parameter"), tag("/function")]
|
|
111
|
+
if envelope:
|
|
112
|
+
parts = [tag("tool_call")] + parts + [tag("/tool_call")]
|
|
113
|
+
return (lead + "\n" + "\n".join(parts)) if lead else "\n".join(parts)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _resp(text):
|
|
117
|
+
return {
|
|
118
|
+
"choices": [
|
|
119
|
+
{"finish_reason": "stop", "message": {"role": "assistant", "content": text}}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def test_enveloped_markup_never_reaches_the_client(self):
|
|
124
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
125
|
+
self._resp(self._markup()), suppress=True
|
|
126
|
+
)
|
|
127
|
+
content = out["choices"][0]["message"]["content"]
|
|
128
|
+
self.assertNotIn(LT + "tool_call" + GT, content)
|
|
129
|
+
self.assertNotIn(LT + "function=", content)
|
|
130
|
+
self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
|
|
131
|
+
|
|
132
|
+
def test_bare_hermes_block_is_stripped_too(self):
|
|
133
|
+
# _strip_residual_tool_call_xml only knows the envelope; a bare
|
|
134
|
+
# function block walked straight through it.
|
|
135
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
136
|
+
self._resp(self._markup(envelope=False)), suppress=True
|
|
137
|
+
)
|
|
138
|
+
self.assertNotIn(LT + "function=", out["choices"][0]["message"]["content"])
|
|
139
|
+
|
|
140
|
+
def test_unclosed_block_is_stripped_too(self):
|
|
141
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
142
|
+
self._resp(self._markup(envelope=False, closed=False)), suppress=True
|
|
143
|
+
)
|
|
144
|
+
content = out["choices"][0]["message"]["content"]
|
|
145
|
+
self.assertNotIn(LT + "function=", content)
|
|
146
|
+
self.assertNotIn(LT + "parameter=", content)
|
|
147
|
+
|
|
148
|
+
def test_markup_only_reply_gets_fallback_prose(self):
|
|
149
|
+
# An empty assistant message is no better than XML: the client ends the
|
|
150
|
+
# turn either way and the operator sees a blank reply.
|
|
151
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
152
|
+
self._resp(self._markup()), suppress=True
|
|
153
|
+
)
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
out["choices"][0]["message"]["content"], proxy.STUCK_BREAK_PROSE_FALLBACK
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def test_the_model_own_prose_is_kept_when_it_wrote_any(self):
|
|
159
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
160
|
+
self._resp(self._markup(lead="I will check the DB.")), suppress=True
|
|
161
|
+
)
|
|
162
|
+
self.assertEqual(out["choices"][0]["message"]["content"], "I will check the DB.")
|
|
163
|
+
|
|
164
|
+
def test_plain_prose_passes_through_untouched(self):
|
|
165
|
+
# Whitespace and blank lines included: the sanitiser collapses those, so
|
|
166
|
+
# a fixture without them is a fixed point and would pass even if the
|
|
167
|
+
# early exit were deleted.
|
|
168
|
+
text = "\n The cleanup at line 725 takes\n\n\n the lock in the wrong order. \n"
|
|
169
|
+
out = proxy._maybe_extract_text_tool_calls(self._resp(text), suppress=True)
|
|
170
|
+
self.assertEqual(out["choices"][0]["message"]["content"], text)
|
|
171
|
+
|
|
172
|
+
def test_suppression_still_refuses_to_resurrect_the_call(self):
|
|
173
|
+
# The original guarantee must survive the sanitiser.
|
|
174
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
175
|
+
self._resp(self._markup()), suppress=True
|
|
176
|
+
)
|
|
177
|
+
self.assertIsNone(out["choices"][0]["message"].get("tool_calls"))
|
|
178
|
+
self.assertEqual(out["choices"][0]["finish_reason"], "stop")
|
|
179
|
+
|
|
180
|
+
def test_unsuppressed_turns_still_promote_the_call(self):
|
|
181
|
+
# The sanitiser must not leak onto the normal path, where recovering a
|
|
182
|
+
# prose tool call is the whole point.
|
|
183
|
+
out = proxy._maybe_extract_text_tool_calls(
|
|
184
|
+
self._resp(self._markup()), suppress=False
|
|
185
|
+
)
|
|
186
|
+
calls = out["choices"][0]["message"].get("tool_calls") or []
|
|
187
|
+
self.assertEqual([c["function"]["name"] for c in calls], ["bash"])
|
|
188
|
+
self.assertEqual(out["choices"][0]["finish_reason"], "tool_calls")
|
|
189
|
+
|
|
190
|
+
def test_end_to_end_the_anthropic_response_is_a_prose_end_turn(self):
|
|
191
|
+
# BARE hermes, not the enveloped form: the pre-existing
|
|
192
|
+
# _strip_residual_tool_call_xml already deleted a whole <tool_call>
|
|
193
|
+
# envelope downstream, so the enveloped fixture passed on the OLD code
|
|
194
|
+
# too -- with an EMPTY text block, the very outcome this rail rejects.
|
|
195
|
+
resp = self._resp(self._markup(envelope=False))
|
|
196
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
197
|
+
out = proxy.openai_to_anthropic_response(
|
|
198
|
+
resp, "qwen", suppress_text_tool_extraction=True
|
|
199
|
+
)
|
|
200
|
+
self.assertEqual(out["stop_reason"], "end_turn")
|
|
201
|
+
blocks = out.get("content") or []
|
|
202
|
+
self.assertTrue(blocks, "a suppressed turn must still carry a text block")
|
|
203
|
+
self.assertTrue(all(b.get("type") == "text" for b in blocks))
|
|
204
|
+
joined = "".join(b.get("text") or "" for b in blocks)
|
|
205
|
+
# An empty reply is as useless to the client as the markup was.
|
|
206
|
+
self.assertTrue(joined.strip(), "the turn must end in ACTUAL prose")
|
|
207
|
+
self.assertNotIn(LT + "function=", joined)
|
|
208
|
+
self.assertNotIn(LT + "tool_call" + GT, joined)
|
|
209
|
+
|
|
210
|
+
|
|
86
211
|
if __name__ == "__main__":
|
|
87
212
|
unittest.main()
|
|
213
|
+
|
|
214
|
+
class TestSuppressedMarkupLeakClasses(unittest.TestCase):
|
|
215
|
+
"""Every markup shape that reached a client, and every prose shape that must not be eaten.
|
|
216
|
+
|
|
217
|
+
The sanitiser deletes text, so its failure modes run both ways: markup left
|
|
218
|
+
behind is the original bug, and prose destroyed is a new one. Both are
|
|
219
|
+
pinned here.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _clean(text):
|
|
224
|
+
return proxy._strip_all_tool_call_markup(text)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _tag(name):
|
|
228
|
+
return LT + name + GT
|
|
229
|
+
|
|
230
|
+
def _block(self, name="bash", closed=True, param=True):
|
|
231
|
+
parts = [self._tag(f"function={name}")]
|
|
232
|
+
if param:
|
|
233
|
+
parts += [self._tag("parameter=command"), "grep X ."]
|
|
234
|
+
if closed:
|
|
235
|
+
parts.append(self._tag("/parameter"))
|
|
236
|
+
else:
|
|
237
|
+
parts.append("body")
|
|
238
|
+
if closed:
|
|
239
|
+
parts.append(self._tag("/function"))
|
|
240
|
+
return "".join(parts)
|
|
241
|
+
|
|
242
|
+
# --- markup that must go ---
|
|
243
|
+
|
|
244
|
+
def test_strips_a_dotted_or_hyphenated_tool_name(self):
|
|
245
|
+
# The PARSER's name class is [A-Za-z_][A-Za-z0-9_]*, so it ignored these
|
|
246
|
+
# -- and the old stripper reused it, leaving a half-eaten block behind.
|
|
247
|
+
for name in ("web.search", "read-file", "2fa_check"):
|
|
248
|
+
with self.subTest(name=name):
|
|
249
|
+
self.assertEqual(self._clean(self._block(name)), "")
|
|
250
|
+
|
|
251
|
+
def test_strips_an_unclosed_block_that_carries_a_parameter(self):
|
|
252
|
+
self.assertEqual(self._clean(self._block(closed=False)), "")
|
|
253
|
+
|
|
254
|
+
def test_strips_an_orphan_closing_tag(self):
|
|
255
|
+
self.assertEqual(self._clean("All done." + self._tag("/function")), "All done.")
|
|
256
|
+
self.assertEqual(self._clean("Done." + self._tag("/tool_call")), "Done.")
|
|
257
|
+
self.assertEqual(self._clean("Done." + self._tag("/parameter")), "Done.")
|
|
258
|
+
|
|
259
|
+
def test_strips_an_unclosed_gemma_dsl_call(self):
|
|
260
|
+
# The Gemma parsing regex has no premature-EOS arm, so a truncated call
|
|
261
|
+
# passed through whole.
|
|
262
|
+
dsl = LT + "|tool_call" + GT + "call: bash {c:1}"
|
|
263
|
+
self.assertEqual(self._clean("Done.\n" + dsl), "Done.")
|
|
264
|
+
|
|
265
|
+
# --- prose that must survive ---
|
|
266
|
+
|
|
267
|
+
def test_prose_that_merely_mentions_a_tag_keeps_its_sentence(self):
|
|
268
|
+
# The hard-tier directive asks the model what the repeated call
|
|
269
|
+
# returned, i.e. it invites naming the call. A \Z-anchored delete turned
|
|
270
|
+
# that into "The proxy scans for".
|
|
271
|
+
text = (
|
|
272
|
+
"The proxy scans for "
|
|
273
|
+
+ self._tag("function=bash")
|
|
274
|
+
+ " and then deletes the rest. THIS SHOULD SURVIVE."
|
|
275
|
+
)
|
|
276
|
+
out = self._clean(text)
|
|
277
|
+
self.assertIn("THIS SHOULD SURVIVE.", out)
|
|
278
|
+
self.assertIn("The proxy scans for", out)
|
|
279
|
+
|
|
280
|
+
def test_prose_between_two_blocks_survives(self):
|
|
281
|
+
text = (
|
|
282
|
+
"Alpha.\n"
|
|
283
|
+
+ self._block("b")
|
|
284
|
+
+ "\nBETWEEN-PROSE\n"
|
|
285
|
+
+ self._block("d")
|
|
286
|
+
+ "\nOmega."
|
|
287
|
+
)
|
|
288
|
+
out = self._clean(text)
|
|
289
|
+
self.assertIn("BETWEEN-PROSE", out)
|
|
290
|
+
self.assertIn("Alpha.", out)
|
|
291
|
+
self.assertIn("Omega.", out)
|
|
292
|
+
|
|
293
|
+
def test_a_malformed_block_does_not_swallow_the_next_blocks_prose(self):
|
|
294
|
+
# One unclosed opener used to match through the NEXT block's </function>.
|
|
295
|
+
text = (
|
|
296
|
+
"Alpha.\n"
|
|
297
|
+
+ self._tag("function=bash")
|
|
298
|
+
+ "X\nIMPORTANT PROSE\n"
|
|
299
|
+
+ self._block("read")
|
|
300
|
+
+ "\nOmega."
|
|
301
|
+
)
|
|
302
|
+
out = self._clean(text)
|
|
303
|
+
self.assertIn("IMPORTANT PROSE", out)
|
|
304
|
+
self.assertNotIn(LT + "function=", out)
|
|
305
|
+
|
|
306
|
+
def test_a_fenced_json_call_is_deliberately_left_alone(self):
|
|
307
|
+
# The normal path schema-matches these; here they are indistinguishable
|
|
308
|
+
# from a model quoting JSON, and destroying real output is worse.
|
|
309
|
+
text = 'I will run it.\n\n```json\n{"name": "bash"}\n```'
|
|
310
|
+
self.assertEqual(self._clean(text), text)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class TestSuppressedSanitiserReach(unittest.TestCase):
|
|
314
|
+
"""Fields and shapes the sanitiser has to reach, beyond choices[0].content."""
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _markup():
|
|
318
|
+
return (
|
|
319
|
+
LT + "function=bash" + GT + LT + "parameter=command" + GT
|
|
320
|
+
+ "grep X ." + LT + "/parameter" + GT + LT + "/function" + GT
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def test_scrubs_the_reasoning_sidecar_too(self):
|
|
324
|
+
# With content empty, the EMPTY-OUTPUT GUARD promotes reasoning into the
|
|
325
|
+
# VISIBLE text -- leaving it alone just relocates the leak.
|
|
326
|
+
resp = {"choices": [{"message": {"role": "assistant", "content": "", "reasoning_content": self._markup()}}]}
|
|
327
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
328
|
+
self.assertNotIn(LT + "function=", resp["choices"][0]["message"]["reasoning_content"])
|
|
329
|
+
|
|
330
|
+
def test_a_think_wrapped_call_still_yields_visible_prose(self):
|
|
331
|
+
# "<think></think>" is truthy but renders blank. Emptiness has to be
|
|
332
|
+
# judged on what survives thinking extraction.
|
|
333
|
+
text = LT + "think" + GT + LT + "/think" + GT + self._markup()
|
|
334
|
+
resp = {"choices": [{"message": {"role": "assistant", "content": text}}]}
|
|
335
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
336
|
+
_, visible = proxy._extract_thinking_block(resp["choices"][0]["message"]["content"])
|
|
337
|
+
self.assertTrue(visible.strip(), "a think-only reply renders blank to the client")
|
|
338
|
+
|
|
339
|
+
def test_scrubs_every_choice_not_just_the_first(self):
|
|
340
|
+
resp = {"choices": [
|
|
341
|
+
{"message": {"role": "assistant", "content": "fine"}},
|
|
342
|
+
{"message": {"role": "assistant", "content": self._markup()}},
|
|
343
|
+
]}
|
|
344
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
345
|
+
self.assertNotIn(LT + "function=", resp["choices"][1]["message"]["content"])
|
|
346
|
+
|
|
347
|
+
def test_leaves_a_message_that_has_real_tool_calls_alone(self):
|
|
348
|
+
# Rewriting the text beside a live tool call would attach an apology to
|
|
349
|
+
# a turn that is actually doing work.
|
|
350
|
+
mk = self._markup()
|
|
351
|
+
resp = {"choices": [{"message": {"role": "assistant", "content": mk, "tool_calls": [{"id": "x"}]}}]}
|
|
352
|
+
proxy._maybe_extract_text_tool_calls(resp, suppress=True)
|
|
353
|
+
self.assertEqual(resp["choices"][0]["message"]["content"], mk)
|
|
354
|
+
|
|
355
|
+
def test_survives_a_malformed_upstream_payload(self):
|
|
356
|
+
# A degraded turn must not become a 500.
|
|
357
|
+
for bad in ({"choices": [{"message": None}]}, {"choices": ["oops"]}, {"choices": []}, {}):
|
|
358
|
+
with self.subTest(bad=bad):
|
|
359
|
+
proxy._maybe_extract_text_tool_calls(dict(bad), suppress=True)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class TestSuppressedTurnIsBuffered(unittest.TestCase):
|
|
363
|
+
"""A tools-stripped turn must not be streamed.
|
|
364
|
+
|
|
365
|
+
The sanitiser only runs on the buffered path -- a streamed turn is already
|
|
366
|
+
on the wire before anything can inspect it. And the hard tier pops
|
|
367
|
+
`tool_choice`, which is precisely the key _should_use_guarded_non_stream
|
|
368
|
+
requires, so stripping the tools ALSO routed the turn away from the guarded
|
|
369
|
+
path. That is why the request handler ORs the monitor flag in.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _hard_tier_body():
|
|
374
|
+
# What _maybe_inject_stuck_break leaves behind at the hard tier: no
|
|
375
|
+
# tools, no tool_choice, no grammar.
|
|
376
|
+
return {"messages": [{"role": "user", "content": "go"}]}
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _monitor(suppress):
|
|
380
|
+
mon = proxy.SessionMonitor()
|
|
381
|
+
mon.suppress_text_tool_extraction = suppress
|
|
382
|
+
return mon
|
|
383
|
+
|
|
384
|
+
def test_a_suppressed_turn_is_always_buffered(self):
|
|
385
|
+
# Regardless of stream flag, body shape, or any ambient proxy config:
|
|
386
|
+
# the sanitiser only runs on the buffered path.
|
|
387
|
+
for is_stream in (True, False):
|
|
388
|
+
with self.subTest(is_stream=is_stream):
|
|
389
|
+
self.assertTrue(
|
|
390
|
+
proxy._should_buffer_turn(
|
|
391
|
+
is_stream,
|
|
392
|
+
{"stream": is_stream},
|
|
393
|
+
self._hard_tier_body(),
|
|
394
|
+
self._monitor(True),
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def test_an_ordinary_turn_defers_to_the_existing_check(self):
|
|
399
|
+
# The new reason must ADD to the old rule, never replace it.
|
|
400
|
+
body = self._hard_tier_body()
|
|
401
|
+
mon = self._monitor(False)
|
|
402
|
+
self.assertEqual(
|
|
403
|
+
proxy._should_buffer_turn(True, {"stream": True}, body, mon),
|
|
404
|
+
proxy._should_use_guarded_non_stream(True, {"stream": True}, body),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def test_suppression_overrides_a_declining_guarded_check(self):
|
|
408
|
+
# Pin the actual defect: with the flag off this exact turn is NOT
|
|
409
|
+
# buffered by the old rule alone, and with it on it is.
|
|
410
|
+
body = {"stream": True}
|
|
411
|
+
openai_body = self._hard_tier_body()
|
|
412
|
+
if proxy._should_use_guarded_non_stream(True, body, openai_body):
|
|
413
|
+
self.skipTest("ambient proxy config already forces buffering here")
|
|
414
|
+
self.assertFalse(proxy._should_buffer_turn(True, body, openai_body, self._monitor(False)))
|
|
415
|
+
self.assertTrue(proxy._should_buffer_turn(True, body, openai_body, self._monitor(True)))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""An advertised alias is a servable model id.
|
|
3
|
+
|
|
4
|
+
llama-server registers aliases in a std::set and reports ONE primary `id` --
|
|
5
|
+
`*begin()`, i.e. the ASCII-first entry -- alongside an `aliases` array carrying
|
|
6
|
+
all of them. The proxy read only `id`, so every client configured with any other
|
|
7
|
+
registered alias looked unservable and had its model rewritten on EVERY request
|
|
8
|
+
(MODEL REWRITE, 6 in a 3h window on 2026-08-25).
|
|
9
|
+
|
|
10
|
+
That made --alias half-useless for the thing it was added to fix: naming legacy
|
|
11
|
+
ids alongside a new one so an older client config keeps resolving untouched.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import importlib.util
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_proxy_module():
|
|
21
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
22
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
23
|
+
assert spec is not None and spec.loader is not None
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
spec.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
proxy = _load_proxy_module()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _FakeResponse:
|
|
33
|
+
def __init__(self, payload, status_code=200):
|
|
34
|
+
self._payload = payload
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.text = str(payload)
|
|
37
|
+
|
|
38
|
+
def json(self):
|
|
39
|
+
return self._payload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _FakeClient:
|
|
43
|
+
"""Stands in for the httpx client; records nothing, just answers /models."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, payload, status_code=200):
|
|
46
|
+
self._response = _FakeResponse(payload, status_code)
|
|
47
|
+
|
|
48
|
+
async def get(self, _url, timeout=None): # noqa: ARG002 - signature parity
|
|
49
|
+
return self._response
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UpstreamModelAliasTests(unittest.TestCase):
|
|
53
|
+
def setUp(self):
|
|
54
|
+
# The result is cached in a module global for the process lifetime.
|
|
55
|
+
self._saved_client = proxy.http_client
|
|
56
|
+
proxy._upstream_model_ids = None
|
|
57
|
+
|
|
58
|
+
def tearDown(self):
|
|
59
|
+
proxy.http_client = self._saved_client
|
|
60
|
+
proxy._upstream_model_ids = None
|
|
61
|
+
|
|
62
|
+
def _ids(self, payload, status_code=200):
|
|
63
|
+
proxy.http_client = _FakeClient(payload, status_code)
|
|
64
|
+
return asyncio.run(proxy._upstream_model_ids_cached())
|
|
65
|
+
|
|
66
|
+
def test_aliases_are_reported_as_served_ids(self):
|
|
67
|
+
ids = self._ids(
|
|
68
|
+
{
|
|
69
|
+
"data": [
|
|
70
|
+
{
|
|
71
|
+
"id": "Qwen3.8-27B",
|
|
72
|
+
"aliases": ["Qwen3.8-27B", "qwen36-35b-a3b-iq4xs", "qwen35-a3b-iq4xs"],
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
# The legacy ids are exactly the ones that were being rewritten.
|
|
78
|
+
self.assertIn("qwen36-35b-a3b-iq4xs", ids)
|
|
79
|
+
self.assertIn("qwen35-a3b-iq4xs", ids)
|
|
80
|
+
self.assertIn("Qwen3.8-27B", ids)
|
|
81
|
+
|
|
82
|
+
def test_the_primary_id_stays_first(self):
|
|
83
|
+
# Callers pick ids[0] as the rewrite target, so the primary must lead
|
|
84
|
+
# even though it also appears in the aliases array.
|
|
85
|
+
ids = self._ids({"data": [{"id": "Primary", "aliases": ["Primary", "Alt"]}]})
|
|
86
|
+
self.assertEqual(ids[0], "Primary")
|
|
87
|
+
|
|
88
|
+
def test_the_primary_id_is_not_duplicated_by_its_own_alias_entry(self):
|
|
89
|
+
# llama-server lists the primary inside `aliases` too.
|
|
90
|
+
ids = self._ids({"data": [{"id": "Same", "aliases": ["Same"]}]})
|
|
91
|
+
self.assertEqual(ids, ["Same"])
|
|
92
|
+
|
|
93
|
+
def test_a_payload_with_no_aliases_key_still_works(self):
|
|
94
|
+
# Older llama-server builds, and any other OpenAI-compatible upstream.
|
|
95
|
+
ids = self._ids({"data": [{"id": "OnlyId"}]})
|
|
96
|
+
self.assertEqual(ids, ["OnlyId"])
|
|
97
|
+
|
|
98
|
+
def test_junk_entries_are_skipped_rather_than_raising(self):
|
|
99
|
+
# Discovery must never take the proxy down.
|
|
100
|
+
ids = self._ids(
|
|
101
|
+
{
|
|
102
|
+
"data": [
|
|
103
|
+
"not-a-dict",
|
|
104
|
+
{"no_id": True},
|
|
105
|
+
{"id": "Good", "aliases": [None, "", "AlsoGood"]},
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
self.assertEqual(ids, ["Good", "AlsoGood"])
|
|
110
|
+
|
|
111
|
+
def test_a_non_200_yields_no_ids(self):
|
|
112
|
+
self.assertIsNone(self._ids({"data": [{"id": "x"}]}, status_code=503))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|