@miller-tech/uap 1.15.7 → 1.15.9
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
CHANGED
|
@@ -76,6 +76,7 @@ Dependencies
|
|
|
76
76
|
"""
|
|
77
77
|
|
|
78
78
|
import asyncio
|
|
79
|
+
import copy
|
|
79
80
|
import hashlib
|
|
80
81
|
import json
|
|
81
82
|
import logging
|
|
@@ -305,6 +306,44 @@ def _load_tool_call_grammar(path: str) -> str:
|
|
|
305
306
|
|
|
306
307
|
|
|
307
308
|
TOOL_CALL_GBNF = _load_tool_call_grammar(PROXY_TOOL_CALL_GRAMMAR_PATH)
|
|
309
|
+
TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE = True
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_grammar_tools_incompatibility(status_code: int, error_text: str) -> bool:
|
|
313
|
+
if status_code != 400:
|
|
314
|
+
return False
|
|
315
|
+
lowered = (error_text or "").lower()
|
|
316
|
+
return "custom grammar constraints" in lowered and "with tools" in lowered
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _maybe_disable_grammar_for_tools_error(
|
|
320
|
+
request_body: dict,
|
|
321
|
+
status_code: int,
|
|
322
|
+
error_text: str,
|
|
323
|
+
source: str,
|
|
324
|
+
) -> bool:
|
|
325
|
+
global TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE
|
|
326
|
+
|
|
327
|
+
if "grammar" not in request_body or not request_body.get("tools"):
|
|
328
|
+
return False
|
|
329
|
+
if not _is_grammar_tools_incompatibility(status_code, error_text):
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
request_body.pop("grammar", None)
|
|
333
|
+
if TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE:
|
|
334
|
+
TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE = False
|
|
335
|
+
logger.warning(
|
|
336
|
+
"Tool-call grammar rejected by upstream for tool turns; "
|
|
337
|
+
"disabling grammar-on-tools for this proxy process (%s)",
|
|
338
|
+
source,
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
logger.warning(
|
|
342
|
+
"Tool-call grammar already disabled for tool turns; retrying %s without grammar",
|
|
343
|
+
source,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return True
|
|
308
347
|
|
|
309
348
|
|
|
310
349
|
def _apply_tool_call_grammar(
|
|
@@ -318,6 +357,9 @@ def _apply_tool_call_grammar(
|
|
|
318
357
|
if not request_body.get("tools"):
|
|
319
358
|
return
|
|
320
359
|
|
|
360
|
+
if not TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE:
|
|
361
|
+
return
|
|
362
|
+
|
|
321
363
|
effective_tool_choice = (
|
|
322
364
|
tool_choice if tool_choice is not None else request_body.get("tool_choice")
|
|
323
365
|
)
|
|
@@ -937,7 +979,7 @@ async def lifespan(app: FastAPI):
|
|
|
937
979
|
_resolve_prune_target_fraction() * 100,
|
|
938
980
|
)
|
|
939
981
|
logger.info(
|
|
940
|
-
"Guardrails: malformed=%s stream_strict=%s force_non_stream=%s args_preflight=%s tool_narrowing=%s thinking_off_on_tools=%s dampener=%s(%d/%d/%d/%d->%d) contamination_breaker=%s(%d forced=%d required_miss=%d) analysis_only_route=%s(min_tools=%d,max_msgs=%d) grammar=%s(required_only=%s loaded=%s path=%s)",
|
|
982
|
+
"Guardrails: malformed=%s stream_strict=%s force_non_stream=%s args_preflight=%s tool_narrowing=%s thinking_off_on_tools=%s dampener=%s(%d/%d/%d/%d->%d) contamination_breaker=%s(%d forced=%d required_miss=%d) analysis_only_route=%s(min_tools=%d,max_msgs=%d) grammar=%s(required_only=%s loaded=%s tools_compatible=%s path=%s)",
|
|
941
983
|
PROXY_MALFORMED_TOOL_GUARDRAIL,
|
|
942
984
|
PROXY_MALFORMED_TOOL_STREAM_STRICT,
|
|
943
985
|
PROXY_FORCE_NON_STREAM,
|
|
@@ -960,6 +1002,7 @@ async def lifespan(app: FastAPI):
|
|
|
960
1002
|
PROXY_TOOL_CALL_GRAMMAR,
|
|
961
1003
|
PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY,
|
|
962
1004
|
bool(TOOL_CALL_GBNF),
|
|
1005
|
+
TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE,
|
|
963
1006
|
PROXY_TOOL_CALL_GRAMMAR_PATH,
|
|
964
1007
|
)
|
|
965
1008
|
|
|
@@ -1915,6 +1958,20 @@ def _sanitize_markup_value(value):
|
|
|
1915
1958
|
return value, False
|
|
1916
1959
|
|
|
1917
1960
|
|
|
1961
|
+
_REQUIRED_PLACEHOLDER = "__uap_required__"
|
|
1962
|
+
_MISSING_REQUIRED_VALUE = object()
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def _contains_required_placeholder(value) -> bool:
|
|
1966
|
+
if isinstance(value, str):
|
|
1967
|
+
return value.strip() == _REQUIRED_PLACEHOLDER
|
|
1968
|
+
if isinstance(value, list):
|
|
1969
|
+
return any(_contains_required_placeholder(item) for item in value)
|
|
1970
|
+
if isinstance(value, dict):
|
|
1971
|
+
return any(_contains_required_placeholder(item) for item in value.values())
|
|
1972
|
+
return False
|
|
1973
|
+
|
|
1974
|
+
|
|
1918
1975
|
def _repair_tool_call_markup(openai_resp: dict) -> tuple[dict, int]:
|
|
1919
1976
|
if not _openai_has_tool_calls(openai_resp):
|
|
1920
1977
|
return openai_resp, 0
|
|
@@ -1986,33 +2043,30 @@ def _repair_tool_call_markup(openai_resp: dict) -> tuple[dict, int]:
|
|
|
1986
2043
|
|
|
1987
2044
|
|
|
1988
2045
|
def _default_required_value(field_name: str, field_schema: dict):
|
|
1989
|
-
|
|
1990
|
-
if isinstance(
|
|
1991
|
-
|
|
2046
|
+
_ = field_name
|
|
2047
|
+
if not isinstance(field_schema, dict):
|
|
2048
|
+
return _MISSING_REQUIRED_VALUE
|
|
2049
|
+
|
|
2050
|
+
if "default" in field_schema:
|
|
2051
|
+
default_value = copy.deepcopy(field_schema.get("default"))
|
|
2052
|
+
if not _contains_required_placeholder(default_value):
|
|
2053
|
+
return default_value
|
|
2054
|
+
|
|
2055
|
+
enum_values = field_schema.get("enum")
|
|
2056
|
+
if isinstance(enum_values, list):
|
|
2057
|
+
for candidate in enum_values:
|
|
2058
|
+
if _required_value_is_empty(candidate):
|
|
2059
|
+
continue
|
|
2060
|
+
if _contains_required_placeholder(candidate):
|
|
2061
|
+
continue
|
|
2062
|
+
return copy.deepcopy(candidate)
|
|
1992
2063
|
|
|
1993
|
-
if
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
if expected_type == "object":
|
|
2000
|
-
return {"value": "__uap_required__"}
|
|
2001
|
-
if expected_type == "array":
|
|
2002
|
-
return ["__uap_required__"]
|
|
2003
|
-
|
|
2004
|
-
key = (field_name or "").lower()
|
|
2005
|
-
if key in {"command", "cmd"}:
|
|
2006
|
-
return "pwd"
|
|
2007
|
-
if key == "cron":
|
|
2008
|
-
return "* * * * *"
|
|
2009
|
-
if key in {"pattern", "glob"}:
|
|
2010
|
-
return "*"
|
|
2011
|
-
if key == "subject":
|
|
2012
|
-
return "task"
|
|
2013
|
-
if key in {"path", "file", "filepath", "file_path"} or key.endswith("_path"):
|
|
2014
|
-
return "."
|
|
2015
|
-
return "__uap_required__"
|
|
2064
|
+
if "const" in field_schema:
|
|
2065
|
+
const_value = copy.deepcopy(field_schema.get("const"))
|
|
2066
|
+
if not _contains_required_placeholder(const_value):
|
|
2067
|
+
return const_value
|
|
2068
|
+
|
|
2069
|
+
return _MISSING_REQUIRED_VALUE
|
|
2016
2070
|
|
|
2017
2071
|
|
|
2018
2072
|
def _repair_required_tool_args(
|
|
@@ -2075,7 +2129,10 @@ def _repair_required_tool_args(
|
|
|
2075
2129
|
if isinstance(properties.get(field), dict)
|
|
2076
2130
|
else {}
|
|
2077
2131
|
)
|
|
2078
|
-
|
|
2132
|
+
fallback_value = _default_required_value(field, field_schema)
|
|
2133
|
+
if fallback_value is _MISSING_REQUIRED_VALUE:
|
|
2134
|
+
continue
|
|
2135
|
+
parsed_args[field] = fallback_value
|
|
2079
2136
|
changed = True
|
|
2080
2137
|
|
|
2081
2138
|
if not changed:
|
|
@@ -2298,6 +2355,18 @@ def _validate_tool_call_arguments(
|
|
|
2298
2355
|
),
|
|
2299
2356
|
)
|
|
2300
2357
|
|
|
2358
|
+
if _contains_required_placeholder(parsed):
|
|
2359
|
+
return ToolResponseIssue(
|
|
2360
|
+
kind="invalid_tool_args",
|
|
2361
|
+
reason=(
|
|
2362
|
+
f"arguments for '{tool_name}' contain unresolved placeholder values"
|
|
2363
|
+
),
|
|
2364
|
+
retry_hint=(
|
|
2365
|
+
f"Emit exactly one `{tool_name}` tool call with real schema-valid arguments. "
|
|
2366
|
+
f"Never emit `{_REQUIRED_PLACEHOLDER}` placeholders."
|
|
2367
|
+
),
|
|
2368
|
+
)
|
|
2369
|
+
|
|
2301
2370
|
if not isinstance(tool_schema, dict):
|
|
2302
2371
|
tool_schema = {}
|
|
2303
2372
|
|
|
@@ -2312,6 +2381,7 @@ def _validate_tool_call_arguments(
|
|
|
2312
2381
|
missing: list[str] = []
|
|
2313
2382
|
empty: list[str] = []
|
|
2314
2383
|
wrong_type: list[str] = []
|
|
2384
|
+
enum_mismatch: list[str] = []
|
|
2315
2385
|
|
|
2316
2386
|
for field in required:
|
|
2317
2387
|
if not isinstance(field, str):
|
|
@@ -2334,6 +2404,15 @@ def _validate_tool_call_arguments(
|
|
|
2334
2404
|
wrong_type.append(field)
|
|
2335
2405
|
continue
|
|
2336
2406
|
|
|
2407
|
+
enum_values = schema.get("enum")
|
|
2408
|
+
if isinstance(enum_values, list) and enum_values and value not in enum_values:
|
|
2409
|
+
enum_mismatch.append(field)
|
|
2410
|
+
continue
|
|
2411
|
+
|
|
2412
|
+
if "const" in schema and value != schema.get("const"):
|
|
2413
|
+
enum_mismatch.append(field)
|
|
2414
|
+
continue
|
|
2415
|
+
|
|
2337
2416
|
min_length = schema.get("minLength")
|
|
2338
2417
|
if (
|
|
2339
2418
|
isinstance(min_length, int)
|
|
@@ -2351,7 +2430,7 @@ def _validate_tool_call_arguments(
|
|
|
2351
2430
|
):
|
|
2352
2431
|
empty.append(field)
|
|
2353
2432
|
|
|
2354
|
-
if missing or empty or wrong_type:
|
|
2433
|
+
if missing or empty or wrong_type or enum_mismatch:
|
|
2355
2434
|
details = []
|
|
2356
2435
|
if missing:
|
|
2357
2436
|
details.append(f"missing: {', '.join(missing)}")
|
|
@@ -2359,6 +2438,8 @@ def _validate_tool_call_arguments(
|
|
|
2359
2438
|
details.append(f"empty: {', '.join(empty)}")
|
|
2360
2439
|
if wrong_type:
|
|
2361
2440
|
details.append(f"type mismatch: {', '.join(wrong_type)}")
|
|
2441
|
+
if enum_mismatch:
|
|
2442
|
+
details.append(f"enum mismatch: {', '.join(enum_mismatch)}")
|
|
2362
2443
|
required_fields = ", ".join(str(f) for f in required if isinstance(f, str))
|
|
2363
2444
|
required_hint = (
|
|
2364
2445
|
f"Required fields must be non-empty: {required_fields}. "
|
|
@@ -3442,6 +3523,20 @@ async def messages(request: Request):
|
|
|
3442
3523
|
headers={"Content-Type": "application/json"},
|
|
3443
3524
|
)
|
|
3444
3525
|
|
|
3526
|
+
if strict_resp.status_code != 200:
|
|
3527
|
+
error_text = strict_resp.text[:1000]
|
|
3528
|
+
if _maybe_disable_grammar_for_tools_error(
|
|
3529
|
+
strict_body,
|
|
3530
|
+
strict_resp.status_code,
|
|
3531
|
+
error_text,
|
|
3532
|
+
"strict-stream",
|
|
3533
|
+
):
|
|
3534
|
+
strict_resp = await client.post(
|
|
3535
|
+
f"{LLAMA_CPP_BASE}/chat/completions",
|
|
3536
|
+
json=strict_body,
|
|
3537
|
+
headers={"Content-Type": "application/json"},
|
|
3538
|
+
)
|
|
3539
|
+
|
|
3445
3540
|
if strict_resp.status_code != 200:
|
|
3446
3541
|
error_text = strict_resp.text[:1000]
|
|
3447
3542
|
logger.error(
|
|
@@ -3582,6 +3677,35 @@ async def messages(request: Request):
|
|
|
3582
3677
|
error_body = await resp.aread()
|
|
3583
3678
|
await resp.aclose()
|
|
3584
3679
|
error_text = error_body.decode("utf-8", errors="replace")[:1000]
|
|
3680
|
+
if _maybe_disable_grammar_for_tools_error(
|
|
3681
|
+
openai_body,
|
|
3682
|
+
resp.status_code,
|
|
3683
|
+
error_text,
|
|
3684
|
+
"stream",
|
|
3685
|
+
):
|
|
3686
|
+
resp = await client.send(
|
|
3687
|
+
client.build_request(
|
|
3688
|
+
"POST",
|
|
3689
|
+
f"{LLAMA_CPP_BASE}/chat/completions",
|
|
3690
|
+
json=openai_body,
|
|
3691
|
+
headers={"Content-Type": "application/json"},
|
|
3692
|
+
),
|
|
3693
|
+
stream=True,
|
|
3694
|
+
)
|
|
3695
|
+
if resp.status_code == 200:
|
|
3696
|
+
return StreamingResponse(
|
|
3697
|
+
stream_anthropic_response(resp, model, monitor, body),
|
|
3698
|
+
media_type="text/event-stream",
|
|
3699
|
+
headers={
|
|
3700
|
+
"Cache-Control": "no-cache",
|
|
3701
|
+
"Connection": "keep-alive",
|
|
3702
|
+
},
|
|
3703
|
+
)
|
|
3704
|
+
|
|
3705
|
+
error_body = await resp.aread()
|
|
3706
|
+
await resp.aclose()
|
|
3707
|
+
error_text = error_body.decode("utf-8", errors="replace")[:1000]
|
|
3708
|
+
|
|
3585
3709
|
logger.error("Upstream HTTP %d: %s", resp.status_code, error_text)
|
|
3586
3710
|
|
|
3587
3711
|
# Parse the error for a user-friendly message
|
|
@@ -3669,6 +3793,20 @@ async def messages(request: Request):
|
|
|
3669
3793
|
headers={"Content-Type": "application/json"},
|
|
3670
3794
|
)
|
|
3671
3795
|
|
|
3796
|
+
if resp.status_code != 200:
|
|
3797
|
+
error_text = resp.text[:1000]
|
|
3798
|
+
if _maybe_disable_grammar_for_tools_error(
|
|
3799
|
+
openai_body,
|
|
3800
|
+
resp.status_code,
|
|
3801
|
+
error_text,
|
|
3802
|
+
"non-stream",
|
|
3803
|
+
):
|
|
3804
|
+
resp = await client.post(
|
|
3805
|
+
f"{LLAMA_CPP_BASE}/chat/completions",
|
|
3806
|
+
json=openai_body,
|
|
3807
|
+
headers={"Content-Type": "application/json"},
|
|
3808
|
+
)
|
|
3809
|
+
|
|
3672
3810
|
# Option B: Handle non-streaming errors too
|
|
3673
3811
|
if resp.status_code != 200:
|
|
3674
3812
|
error_text = resp.text[:1000]
|
|
@@ -3812,6 +3950,7 @@ async def context_status(request: Request):
|
|
|
3812
3950
|
"required_only": PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY,
|
|
3813
3951
|
"path": PROXY_TOOL_CALL_GRAMMAR_PATH,
|
|
3814
3952
|
"loaded": bool(TOOL_CALL_GBNF),
|
|
3953
|
+
"tools_compatible": TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE,
|
|
3815
3954
|
},
|
|
3816
3955
|
# Loop protection stats
|
|
3817
3956
|
"loop_protection": {
|
|
@@ -518,10 +518,12 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
518
518
|
old_enabled = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR")
|
|
519
519
|
old_required_only = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY")
|
|
520
520
|
old_grammar = getattr(proxy, "TOOL_CALL_GBNF")
|
|
521
|
+
old_tools_compatible = getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE")
|
|
521
522
|
try:
|
|
522
523
|
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", True)
|
|
523
524
|
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", True)
|
|
524
525
|
setattr(proxy, "TOOL_CALL_GBNF", 'root ::= "<tool_call>"')
|
|
526
|
+
setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", True)
|
|
525
527
|
|
|
526
528
|
openai_body = {
|
|
527
529
|
"model": "test",
|
|
@@ -548,6 +550,56 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
548
550
|
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", old_enabled)
|
|
549
551
|
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", old_required_only)
|
|
550
552
|
setattr(proxy, "TOOL_CALL_GBNF", old_grammar)
|
|
553
|
+
setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", old_tools_compatible)
|
|
554
|
+
|
|
555
|
+
def test_apply_tool_call_grammar_skips_when_upstream_tools_are_incompatible(self):
|
|
556
|
+
old_enabled = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR")
|
|
557
|
+
old_required_only = getattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY")
|
|
558
|
+
old_grammar = getattr(proxy, "TOOL_CALL_GBNF")
|
|
559
|
+
old_tools_compatible = getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE")
|
|
560
|
+
try:
|
|
561
|
+
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", True)
|
|
562
|
+
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", True)
|
|
563
|
+
setattr(proxy, "TOOL_CALL_GBNF", 'root ::= "<tool_call>"')
|
|
564
|
+
setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", False)
|
|
565
|
+
|
|
566
|
+
request = {
|
|
567
|
+
"tools": [{"type": "function", "function": {"name": "Read"}}],
|
|
568
|
+
"tool_choice": "required",
|
|
569
|
+
}
|
|
570
|
+
proxy._apply_tool_call_grammar(request)
|
|
571
|
+
|
|
572
|
+
self.assertNotIn("grammar", request)
|
|
573
|
+
finally:
|
|
574
|
+
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR", old_enabled)
|
|
575
|
+
setattr(proxy, "PROXY_TOOL_CALL_GRAMMAR_REQUIRED_ONLY", old_required_only)
|
|
576
|
+
setattr(proxy, "TOOL_CALL_GBNF", old_grammar)
|
|
577
|
+
setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", old_tools_compatible)
|
|
578
|
+
|
|
579
|
+
def test_maybe_disable_grammar_for_tools_error_strips_grammar_and_disables_flag(
|
|
580
|
+
self,
|
|
581
|
+
):
|
|
582
|
+
old_tools_compatible = getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE")
|
|
583
|
+
try:
|
|
584
|
+
setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", True)
|
|
585
|
+
|
|
586
|
+
request = {
|
|
587
|
+
"tools": [{"type": "function", "function": {"name": "Read"}}],
|
|
588
|
+
"grammar": 'root ::= "<tool_call>"',
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
retried = proxy._maybe_disable_grammar_for_tools_error(
|
|
592
|
+
request,
|
|
593
|
+
400,
|
|
594
|
+
'{"error":{"message":"Cannot use custom grammar constraints with tools."}}',
|
|
595
|
+
"unit-test",
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
self.assertTrue(retried)
|
|
599
|
+
self.assertNotIn("grammar", request)
|
|
600
|
+
self.assertFalse(getattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE"))
|
|
601
|
+
finally:
|
|
602
|
+
setattr(proxy, "TOOL_CALL_GRAMMAR_TOOLS_COMPATIBLE", old_tools_compatible)
|
|
551
603
|
|
|
552
604
|
def test_clean_guardrail_response_does_not_promise_future_tool_call(self):
|
|
553
605
|
guardrail = proxy._build_clean_guardrail_openai_response(
|
|
@@ -643,7 +695,11 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
643
695
|
"type": "object",
|
|
644
696
|
"required": ["cron", "command"],
|
|
645
697
|
"properties": {
|
|
646
|
-
"cron": {
|
|
698
|
+
"cron": {
|
|
699
|
+
"type": "string",
|
|
700
|
+
"minLength": 1,
|
|
701
|
+
"default": "* * * * *",
|
|
702
|
+
},
|
|
647
703
|
"command": {"type": "string", "minLength": 1},
|
|
648
704
|
},
|
|
649
705
|
},
|
|
@@ -956,9 +1012,21 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
956
1012
|
"type": "object",
|
|
957
1013
|
"required": ["cron", "pattern", "subject"],
|
|
958
1014
|
"properties": {
|
|
959
|
-
"cron": {
|
|
960
|
-
|
|
961
|
-
|
|
1015
|
+
"cron": {
|
|
1016
|
+
"type": "string",
|
|
1017
|
+
"minLength": 1,
|
|
1018
|
+
"default": "* * * * *",
|
|
1019
|
+
},
|
|
1020
|
+
"pattern": {
|
|
1021
|
+
"type": "string",
|
|
1022
|
+
"minLength": 1,
|
|
1023
|
+
"default": "*",
|
|
1024
|
+
},
|
|
1025
|
+
"subject": {
|
|
1026
|
+
"type": "string",
|
|
1027
|
+
"minLength": 1,
|
|
1028
|
+
"default": "task",
|
|
1029
|
+
},
|
|
962
1030
|
},
|
|
963
1031
|
},
|
|
964
1032
|
}
|
|
@@ -1008,9 +1076,21 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
1008
1076
|
"type": "object",
|
|
1009
1077
|
"required": ["cron", "pattern", "subject"],
|
|
1010
1078
|
"properties": {
|
|
1011
|
-
"cron": {
|
|
1012
|
-
|
|
1013
|
-
|
|
1079
|
+
"cron": {
|
|
1080
|
+
"type": "string",
|
|
1081
|
+
"minLength": 1,
|
|
1082
|
+
"default": "* * * * *",
|
|
1083
|
+
},
|
|
1084
|
+
"pattern": {
|
|
1085
|
+
"type": "string",
|
|
1086
|
+
"minLength": 1,
|
|
1087
|
+
"default": "*",
|
|
1088
|
+
},
|
|
1089
|
+
"subject": {
|
|
1090
|
+
"type": "string",
|
|
1091
|
+
"minLength": 1,
|
|
1092
|
+
"default": "task",
|
|
1093
|
+
},
|
|
1014
1094
|
},
|
|
1015
1095
|
},
|
|
1016
1096
|
}
|
|
@@ -1134,10 +1214,7 @@ class TestMalformedToolGuardrail(unittest.TestCase):
|
|
|
1134
1214
|
)
|
|
1135
1215
|
self.assertTrue(args["cron"].strip())
|
|
1136
1216
|
self.assertTrue(args["command"].strip())
|
|
1137
|
-
self.
|
|
1138
|
-
monitor.arg_preflight_repairs >= 1
|
|
1139
|
-
or monitor.arg_preflight_rejections >= 1
|
|
1140
|
-
)
|
|
1217
|
+
self.assertGreaterEqual(len(fake_client.requests), 1)
|
|
1141
1218
|
if fake_client.requests:
|
|
1142
1219
|
retry_payload = fake_client.requests[0]["kwargs"]["json"]
|
|
1143
1220
|
repair_message = retry_payload["messages"][-1]["content"]
|
|
@@ -1488,6 +1565,139 @@ class TestToolTurnControls(unittest.TestCase):
|
|
|
1488
1565
|
setattr(proxy, "PROXY_ANALYSIS_ONLY_MAX_MESSAGES", old_max_messages)
|
|
1489
1566
|
|
|
1490
1567
|
|
|
1568
|
+
class TestRequiredArgRepair(unittest.TestCase):
|
|
1569
|
+
def test_repair_required_args_uses_schema_enum_value(self):
|
|
1570
|
+
openai_resp = {
|
|
1571
|
+
"choices": [
|
|
1572
|
+
{
|
|
1573
|
+
"message": {
|
|
1574
|
+
"tool_calls": [
|
|
1575
|
+
{
|
|
1576
|
+
"id": "call_1",
|
|
1577
|
+
"function": {
|
|
1578
|
+
"name": "omp_task",
|
|
1579
|
+
"arguments": '{"prompt":"analyze"}',
|
|
1580
|
+
},
|
|
1581
|
+
}
|
|
1582
|
+
]
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
]
|
|
1586
|
+
}
|
|
1587
|
+
anthropic_body = {
|
|
1588
|
+
"tools": [
|
|
1589
|
+
{
|
|
1590
|
+
"name": "omp_task",
|
|
1591
|
+
"input_schema": {
|
|
1592
|
+
"type": "object",
|
|
1593
|
+
"required": ["agent", "prompt"],
|
|
1594
|
+
"properties": {
|
|
1595
|
+
"agent": {
|
|
1596
|
+
"type": "string",
|
|
1597
|
+
"enum": ["task", "explore", "plan"],
|
|
1598
|
+
},
|
|
1599
|
+
"prompt": {"type": "string"},
|
|
1600
|
+
},
|
|
1601
|
+
},
|
|
1602
|
+
}
|
|
1603
|
+
]
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
repaired, repaired_count = proxy._repair_required_tool_args(
|
|
1607
|
+
openai_resp, anthropic_body
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
self.assertEqual(repaired_count, 1)
|
|
1611
|
+
args = json.loads(
|
|
1612
|
+
repaired["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
|
|
1613
|
+
)
|
|
1614
|
+
self.assertEqual(args["agent"], "task")
|
|
1615
|
+
|
|
1616
|
+
def test_repair_required_args_does_not_inject_placeholder_without_schema_defaults(
|
|
1617
|
+
self,
|
|
1618
|
+
):
|
|
1619
|
+
openai_resp = {
|
|
1620
|
+
"choices": [
|
|
1621
|
+
{
|
|
1622
|
+
"message": {
|
|
1623
|
+
"tool_calls": [
|
|
1624
|
+
{
|
|
1625
|
+
"id": "call_1",
|
|
1626
|
+
"function": {
|
|
1627
|
+
"name": "omp_task",
|
|
1628
|
+
"arguments": '{"prompt":"analyze"}',
|
|
1629
|
+
},
|
|
1630
|
+
}
|
|
1631
|
+
]
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
]
|
|
1635
|
+
}
|
|
1636
|
+
anthropic_body = {
|
|
1637
|
+
"tools": [
|
|
1638
|
+
{
|
|
1639
|
+
"name": "omp_task",
|
|
1640
|
+
"input_schema": {
|
|
1641
|
+
"type": "object",
|
|
1642
|
+
"required": ["agent", "prompt"],
|
|
1643
|
+
"properties": {
|
|
1644
|
+
"agent": {"type": "string"},
|
|
1645
|
+
"prompt": {"type": "string"},
|
|
1646
|
+
},
|
|
1647
|
+
},
|
|
1648
|
+
}
|
|
1649
|
+
]
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
repaired, repaired_count = proxy._repair_required_tool_args(
|
|
1653
|
+
openai_resp, anthropic_body
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
self.assertEqual(repaired_count, 0)
|
|
1657
|
+
args = json.loads(
|
|
1658
|
+
repaired["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"]
|
|
1659
|
+
)
|
|
1660
|
+
self.assertNotIn("agent", args)
|
|
1661
|
+
|
|
1662
|
+
def test_validate_tool_args_rejects_placeholder_values(self):
|
|
1663
|
+
issue = proxy._validate_tool_call_arguments(
|
|
1664
|
+
"omp_task",
|
|
1665
|
+
'{"agent":"__uap_required__","prompt":"analyze"}',
|
|
1666
|
+
{
|
|
1667
|
+
"type": "object",
|
|
1668
|
+
"required": ["agent", "prompt"],
|
|
1669
|
+
"properties": {
|
|
1670
|
+
"agent": {"type": "string", "enum": ["task", "explore"]},
|
|
1671
|
+
"prompt": {"type": "string"},
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1674
|
+
{"omp_task"},
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
self.assertTrue(issue.has_issue())
|
|
1678
|
+
self.assertEqual(issue.kind, "invalid_tool_args")
|
|
1679
|
+
self.assertIn("placeholder", issue.reason)
|
|
1680
|
+
|
|
1681
|
+
def test_validate_tool_args_rejects_enum_mismatch(self):
|
|
1682
|
+
issue = proxy._validate_tool_call_arguments(
|
|
1683
|
+
"omp_task",
|
|
1684
|
+
'{"agent":"planner","prompt":"analyze"}',
|
|
1685
|
+
{
|
|
1686
|
+
"type": "object",
|
|
1687
|
+
"required": ["agent", "prompt"],
|
|
1688
|
+
"properties": {
|
|
1689
|
+
"agent": {"type": "string", "enum": ["task", "explore"]},
|
|
1690
|
+
"prompt": {"type": "string"},
|
|
1691
|
+
},
|
|
1692
|
+
},
|
|
1693
|
+
{"omp_task"},
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
self.assertTrue(issue.has_issue())
|
|
1697
|
+
self.assertEqual(issue.kind, "invalid_tool_args")
|
|
1698
|
+
self.assertIn("enum mismatch", issue.reason)
|
|
1699
|
+
|
|
1700
|
+
|
|
1491
1701
|
class TestSessionContaminationBreaker(unittest.TestCase):
|
|
1492
1702
|
def test_contamination_breaker_trims_and_resets_streak(self):
|
|
1493
1703
|
old_enabled = getattr(proxy, "PROXY_SESSION_CONTAMINATION_BREAKER")
|