@miller-tech/uap 1.84.0 → 1.85.0
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 +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +75 -0
- package/tools/agents/scripts/confidence_escalation.py +104 -0
- package/tools/agents/tests/test_confidence_escalation.py +56 -0
- package/tools/agents/tests/test_tool_convert_cache.py +49 -0
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -93,6 +93,11 @@ import sys
|
|
|
93
93
|
import time
|
|
94
94
|
import uuid
|
|
95
95
|
from collections import OrderedDict, defaultdict, deque
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
import confidence_escalation as _ce # serving-layer Confidence recipe
|
|
99
|
+
except Exception: # pragma: no cover - fail open if module missing
|
|
100
|
+
_ce = None
|
|
96
101
|
from dataclasses import dataclass, field
|
|
97
102
|
from pathlib import Path
|
|
98
103
|
|
|
@@ -2792,6 +2797,16 @@ def _should_use_guarded_non_stream(
|
|
|
2792
2797
|
if PROXY_MALFORMED_TOOL_STREAM_STRICT and has_tools:
|
|
2793
2798
|
return True
|
|
2794
2799
|
|
|
2800
|
+
# Confidence-escalation (vLLM "Confidence" recipe) needs the FULL answer
|
|
2801
|
+
# buffered to score it before deciding whether to escalate. Buffer single-
|
|
2802
|
+
# answer (no-tool) streaming turns when escalation is enabled. Default OFF.
|
|
2803
|
+
if not has_tools and _ce is not None:
|
|
2804
|
+
try:
|
|
2805
|
+
if _ce.Settings.from_env().enabled:
|
|
2806
|
+
return True
|
|
2807
|
+
except Exception:
|
|
2808
|
+
pass
|
|
2809
|
+
|
|
2795
2810
|
# A2: when stream-passthrough is enabled, do NOT buffer required-tool turns
|
|
2796
2811
|
# (native tool_choice='required' constrains them); force-non-stream and
|
|
2797
2812
|
# malformed-strict above still apply.
|
|
@@ -3351,7 +3366,28 @@ def anthropic_to_openai_response(anthropic_resp: dict) -> dict:
|
|
|
3351
3366
|
}
|
|
3352
3367
|
|
|
3353
3368
|
|
|
3369
|
+
# A3: the anthropic->openai tool conversion + schema sanitize walks every tool's
|
|
3370
|
+
# (often deeply nested) JSON schema. The tool set is IDENTICAL across every turn
|
|
3371
|
+
# of a session, so this recomputed the same result each turn (observed: ~1
|
|
3372
|
+
# SCHEMA SANITIZE log per turn). Cache by a stable hash of the tool definitions.
|
|
3373
|
+
# Downstream only READS the converted dicts and FILTERS the list (narrowing), so
|
|
3374
|
+
# returning the cached object directly is safe.
|
|
3375
|
+
_TOOL_CONVERT_CACHE: "OrderedDict[str, list]" = OrderedDict()
|
|
3376
|
+
_TOOL_CONVERT_CACHE_MAX = 32
|
|
3377
|
+
|
|
3378
|
+
|
|
3354
3379
|
def _convert_anthropic_tools_to_openai(anthropic_tools: list[dict]) -> list[dict]:
|
|
3380
|
+
cache_key = None
|
|
3381
|
+
try:
|
|
3382
|
+
cache_key = hashlib.sha1(
|
|
3383
|
+
json.dumps(anthropic_tools, sort_keys=True, default=str).encode("utf-8")
|
|
3384
|
+
).hexdigest()
|
|
3385
|
+
except Exception:
|
|
3386
|
+
cache_key = None
|
|
3387
|
+
if cache_key is not None and cache_key in _TOOL_CONVERT_CACHE:
|
|
3388
|
+
_TOOL_CONVERT_CACHE.move_to_end(cache_key)
|
|
3389
|
+
return _TOOL_CONVERT_CACHE[cache_key]
|
|
3390
|
+
|
|
3355
3391
|
converted = []
|
|
3356
3392
|
removed_pattern_fields = 0
|
|
3357
3393
|
for tool in anthropic_tools:
|
|
@@ -3375,6 +3411,10 @@ def _convert_anthropic_tools_to_openai(anthropic_tools: list[dict]) -> list[dict
|
|
|
3375
3411
|
removed_pattern_fields,
|
|
3376
3412
|
len(anthropic_tools),
|
|
3377
3413
|
)
|
|
3414
|
+
if cache_key is not None:
|
|
3415
|
+
_TOOL_CONVERT_CACHE[cache_key] = converted
|
|
3416
|
+
if len(_TOOL_CONVERT_CACHE) > _TOOL_CONVERT_CACHE_MAX:
|
|
3417
|
+
_TOOL_CONVERT_CACHE.popitem(last=False)
|
|
3378
3418
|
return converted
|
|
3379
3419
|
|
|
3380
3420
|
|
|
@@ -7963,6 +8003,40 @@ def openai_to_anthropic_response(
|
|
|
7963
8003
|
}
|
|
7964
8004
|
|
|
7965
8005
|
|
|
8006
|
+
async def _maybe_confidence_escalate(anthropic_resp, anthropic_body, client):
|
|
8007
|
+
"""Confidence recipe: if the cheap primary answer scores below threshold,
|
|
8008
|
+
escalate the same (non-tool) turn to the stronger backend and return its
|
|
8009
|
+
answer. Default OFF; fails open (returns the original) on any problem."""
|
|
8010
|
+
if _ce is None or not isinstance(anthropic_resp, dict):
|
|
8011
|
+
return anthropic_resp
|
|
8012
|
+
try:
|
|
8013
|
+
settings = _ce.Settings.from_env()
|
|
8014
|
+
text = _ce.extract_text(anthropic_resp)
|
|
8015
|
+
if not _ce.should_escalate(text, settings, _has_tool_definitions(anthropic_body)):
|
|
8016
|
+
return anthropic_resp
|
|
8017
|
+
payload = _ce.build_escalation_payload(anthropic_body, settings)
|
|
8018
|
+
url = settings.endpoint.rstrip("/") + "/v1/messages"
|
|
8019
|
+
headers = {
|
|
8020
|
+
"Content-Type": "application/json",
|
|
8021
|
+
"x-api-key": settings.api_key,
|
|
8022
|
+
"anthropic-version": "2023-06-01",
|
|
8023
|
+
}
|
|
8024
|
+
resp = await client.post(url, json=payload, headers=headers, timeout=120.0)
|
|
8025
|
+
if resp.status_code == 200:
|
|
8026
|
+
logger.warning(
|
|
8027
|
+
"CONFIDENCE ESCALATION: primary confidence below %.2f -> escalated to %s",
|
|
8028
|
+
settings.threshold, settings.model,
|
|
8029
|
+
)
|
|
8030
|
+
return resp.json()
|
|
8031
|
+
logger.warning(
|
|
8032
|
+
"CONFIDENCE ESCALATION: backend %s returned %d; keeping primary",
|
|
8033
|
+
settings.model, resp.status_code,
|
|
8034
|
+
)
|
|
8035
|
+
except Exception as exc:
|
|
8036
|
+
logger.warning("CONFIDENCE ESCALATION: failed (%s); keeping primary", exc)
|
|
8037
|
+
return anthropic_resp
|
|
8038
|
+
|
|
8039
|
+
|
|
7966
8040
|
async def _heartbeat_then_buffered(produce_coro, model: str):
|
|
7967
8041
|
"""SSE generator: keep-alive heartbeat wrapper for the guarded-non-stream path.
|
|
7968
8042
|
|
|
@@ -8928,6 +9002,7 @@ async def messages(request: Request):
|
|
|
8928
9002
|
"REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
|
|
8929
9003
|
)
|
|
8930
9004
|
|
|
9005
|
+
anthropic_resp = await _maybe_confidence_escalate(anthropic_resp, body, client)
|
|
8931
9006
|
return anthropic_resp
|
|
8932
9007
|
|
|
8933
9008
|
if PROXY_STREAM_HEARTBEAT_SECS > 0:
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Confidence-escalation looper (vLLM Semantic Router "Confidence" recipe).
|
|
2
|
+
|
|
3
|
+
Try the cheap primary model first; if a confidence signal on its answer is below
|
|
4
|
+
threshold, escalate the SAME request to a stronger backend and return that
|
|
5
|
+
answer instead. Bounded (one escalation), default OFF, fails open. Scoped to
|
|
6
|
+
non-tool (single-answer) turns — escalating a mid-loop tool turn would need the
|
|
7
|
+
stronger model to share the whole tool/context state, which is out of scope for
|
|
8
|
+
this primitive.
|
|
9
|
+
|
|
10
|
+
The confidence signal is intentionally pluggable. This module ships a cheap
|
|
11
|
+
text heuristic; a stronger signal (token logprob margin, or UAP's real
|
|
12
|
+
execution/acceptance GATE-pass — the differentiator vs logprob) can replace
|
|
13
|
+
``text_confidence`` without touching the proxy hook.
|
|
14
|
+
|
|
15
|
+
All config is env-driven and OFF by default:
|
|
16
|
+
PROXY_CONFIDENCE_ESCALATE=on enable the looper
|
|
17
|
+
PROXY_CONFIDENCE_THRESHOLD=0.5 escalate when confidence < this
|
|
18
|
+
PROXY_ESCALATE_MODEL=<id> model id sent to the escalation backend
|
|
19
|
+
PROXY_ESCALATE_ENDPOINT=<url> Anthropic-compatible /v1/messages base
|
|
20
|
+
PROXY_ESCALATE_API_KEY=<key> x-api-key for the escalation backend
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Settings:
|
|
31
|
+
enabled: bool
|
|
32
|
+
threshold: float
|
|
33
|
+
model: str
|
|
34
|
+
endpoint: str
|
|
35
|
+
api_key: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_env(cls) -> "Settings":
|
|
39
|
+
on = os.environ.get("PROXY_CONFIDENCE_ESCALATE", "off").lower() not in {
|
|
40
|
+
"", "0", "off", "false", "no",
|
|
41
|
+
}
|
|
42
|
+
try:
|
|
43
|
+
thr = float(os.environ.get("PROXY_CONFIDENCE_THRESHOLD", "0.5"))
|
|
44
|
+
except ValueError:
|
|
45
|
+
thr = 0.5
|
|
46
|
+
return cls(
|
|
47
|
+
enabled=on,
|
|
48
|
+
threshold=thr,
|
|
49
|
+
model=os.environ.get("PROXY_ESCALATE_MODEL", ""),
|
|
50
|
+
endpoint=os.environ.get("PROXY_ESCALATE_ENDPOINT", ""),
|
|
51
|
+
api_key=os.environ.get("PROXY_ESCALATE_API_KEY", ""),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def backend_configured(self) -> bool:
|
|
55
|
+
return bool(self.model and self.endpoint)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_UNCERTAIN = re.compile(
|
|
59
|
+
r"\b(i'?m not sure|i am not sure|i don'?t know|i cannot|i can'?t (?:help|do|determine)|"
|
|
60
|
+
r"unable to|not certain|no idea|as an ai|i'?m sorry,? but)\b",
|
|
61
|
+
re.I,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def text_confidence(text: str) -> float:
|
|
66
|
+
"""Cheap heuristic confidence in [0,1]. Conservative: only clearly weak
|
|
67
|
+
answers (empty, refusal/uncertainty, trivially short) score low."""
|
|
68
|
+
t = (text or "").strip()
|
|
69
|
+
if not t:
|
|
70
|
+
return 0.0
|
|
71
|
+
if _UNCERTAIN.search(t):
|
|
72
|
+
return 0.2
|
|
73
|
+
if len(t) < 20:
|
|
74
|
+
return 0.3
|
|
75
|
+
return 0.9
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_text(anthropic_resp: dict) -> str:
|
|
79
|
+
"""Concatenate text blocks of an Anthropic message response."""
|
|
80
|
+
parts = []
|
|
81
|
+
for blk in (anthropic_resp or {}).get("content", []) or []:
|
|
82
|
+
if isinstance(blk, dict) and blk.get("type") == "text":
|
|
83
|
+
parts.append(blk.get("text", ""))
|
|
84
|
+
return "".join(parts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def should_escalate(text: str, settings: Settings, has_tools: bool) -> bool:
|
|
88
|
+
if not settings.enabled or not settings.backend_configured():
|
|
89
|
+
return False
|
|
90
|
+
if has_tools: # single-answer scope only
|
|
91
|
+
return False
|
|
92
|
+
return text_confidence(text) < settings.threshold
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_escalation_payload(anthropic_body: dict, settings: Settings) -> dict:
|
|
96
|
+
"""Re-issue the same conversation to the stronger model (no tools)."""
|
|
97
|
+
payload = {
|
|
98
|
+
"model": settings.model,
|
|
99
|
+
"max_tokens": anthropic_body.get("max_tokens", 4096),
|
|
100
|
+
"messages": anthropic_body.get("messages", []),
|
|
101
|
+
}
|
|
102
|
+
if anthropic_body.get("system"):
|
|
103
|
+
payload["system"] = anthropic_body["system"]
|
|
104
|
+
return payload
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tests for the confidence-escalation looper (vLLM Confidence recipe)."""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
mod_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "confidence_escalation.py"
|
|
7
|
+
spec = importlib.util.spec_from_file_location("confidence_escalation", mod_path)
|
|
8
|
+
ce = importlib.util.module_from_spec(spec)
|
|
9
|
+
import sys as _sys; _sys.modules["confidence_escalation"]=ce; spec.loader.exec_module(ce)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def S(enabled=True, threshold=0.5, model="opus", endpoint="http://x/", api_key="k"):
|
|
13
|
+
return ce.Settings(enabled=enabled, threshold=threshold, model=model, endpoint=endpoint, api_key=api_key)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfidenceTest(unittest.TestCase):
|
|
17
|
+
def test_text_confidence_levels(self):
|
|
18
|
+
self.assertEqual(ce.text_confidence(""), 0.0)
|
|
19
|
+
self.assertEqual(ce.text_confidence("I don't know"), 0.2)
|
|
20
|
+
self.assertEqual(ce.text_confidence("ok"), 0.3)
|
|
21
|
+
self.assertGreater(ce.text_confidence("Here is a complete, detailed answer to your question."), 0.5)
|
|
22
|
+
|
|
23
|
+
def test_extract_text(self):
|
|
24
|
+
resp = {"content": [{"type": "text", "text": "a"}, {"type": "tool_use"}, {"type": "text", "text": "b"}]}
|
|
25
|
+
self.assertEqual(ce.extract_text(resp), "ab")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ShouldEscalateTest(unittest.TestCase):
|
|
29
|
+
def test_low_confidence_escalates(self):
|
|
30
|
+
self.assertTrue(ce.should_escalate("I cannot help", S(), has_tools=False))
|
|
31
|
+
|
|
32
|
+
def test_high_confidence_does_not(self):
|
|
33
|
+
self.assertFalse(ce.should_escalate("A thorough and confident full answer here.", S(), has_tools=False))
|
|
34
|
+
|
|
35
|
+
def test_disabled_never(self):
|
|
36
|
+
self.assertFalse(ce.should_escalate("", S(enabled=False), has_tools=False))
|
|
37
|
+
|
|
38
|
+
def test_no_backend_never(self):
|
|
39
|
+
self.assertFalse(ce.should_escalate("", S(endpoint=""), has_tools=False))
|
|
40
|
+
|
|
41
|
+
def test_tool_turn_never(self):
|
|
42
|
+
self.assertFalse(ce.should_escalate("", S(), has_tools=True))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PayloadTest(unittest.TestCase):
|
|
46
|
+
def test_payload_uses_escalation_model_and_messages(self):
|
|
47
|
+
body = {"model": "qwen", "max_tokens": 100, "system": "sys", "messages": [{"role": "user", "content": "hi"}]}
|
|
48
|
+
p = ce.build_escalation_payload(body, S(model="opus"))
|
|
49
|
+
self.assertEqual(p["model"], "opus")
|
|
50
|
+
self.assertEqual(p["messages"], body["messages"])
|
|
51
|
+
self.assertEqual(p["system"], "sys")
|
|
52
|
+
self.assertNotIn("tools", p)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
unittest.main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tests for A3: per-session tool-conversion cache."""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
proxy_path = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
|
|
7
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
8
|
+
ap = importlib.util.module_from_spec(spec)
|
|
9
|
+
spec.loader.exec_module(ap)
|
|
10
|
+
|
|
11
|
+
TOOLS = [
|
|
12
|
+
{"name": "Read", "description": "read", "input_schema": {"type": "object",
|
|
13
|
+
"properties": {"p": {"type": "string", "pattern": "^/.*"}}, "required": ["p"]}},
|
|
14
|
+
{"name": "Bash", "description": "run", "input_schema": {"type": "object",
|
|
15
|
+
"properties": {"cmd": {"type": "string"}}}},
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolConvertCacheTest(unittest.TestCase):
|
|
20
|
+
def setUp(self):
|
|
21
|
+
ap._TOOL_CONVERT_CACHE.clear()
|
|
22
|
+
|
|
23
|
+
def test_correct_conversion_and_sanitize(self):
|
|
24
|
+
out = ap._convert_anthropic_tools_to_openai(TOOLS)
|
|
25
|
+
self.assertEqual(out[0]["function"]["name"], "Read")
|
|
26
|
+
# regex pattern field stripped by sanitize
|
|
27
|
+
self.assertNotIn("pattern", out[0]["function"]["parameters"]["properties"]["p"])
|
|
28
|
+
|
|
29
|
+
def test_second_call_is_cache_hit_same_object(self):
|
|
30
|
+
a = ap._convert_anthropic_tools_to_openai(TOOLS)
|
|
31
|
+
self.assertEqual(len(ap._TOOL_CONVERT_CACHE), 1)
|
|
32
|
+
b = ap._convert_anthropic_tools_to_openai([dict(t) for t in TOOLS]) # equal-by-value
|
|
33
|
+
self.assertIs(a, b, "identical tool set must return the cached object")
|
|
34
|
+
|
|
35
|
+
def test_different_tools_miss(self):
|
|
36
|
+
ap._convert_anthropic_tools_to_openai(TOOLS)
|
|
37
|
+
ap._convert_anthropic_tools_to_openai(TOOLS[:1])
|
|
38
|
+
self.assertEqual(len(ap._TOOL_CONVERT_CACHE), 2)
|
|
39
|
+
|
|
40
|
+
def test_cache_bounded(self):
|
|
41
|
+
for i in range(ap._TOOL_CONVERT_CACHE_MAX + 5):
|
|
42
|
+
ap._convert_anthropic_tools_to_openai(
|
|
43
|
+
[{"name": f"T{i}", "description": "", "input_schema": {"type": "object"}}]
|
|
44
|
+
)
|
|
45
|
+
self.assertLessEqual(len(ap._TOOL_CONVERT_CACHE), ap._TOOL_CONVERT_CACHE_MAX)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
unittest.main()
|