diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index dc8465923..3f9876f18 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -535,8 +535,9 @@ def _validate_responses_parallel_tool_calls(body: dict[str, Any]) -> bool | None if "parallel_tool_calls" not in body: return None value = body.get("parallel_tool_calls") - # Explicit JSON null is treat-as-omit (SDK optional default). - if value is None: + # Explicit JSON null or empty/whitespace string is treat-as-omit + # (SDK optional default / stringified empty control). + if value is None or (isinstance(value, str) and not value.strip()): return None if not isinstance(value, bool): raise RequestError( @@ -1087,6 +1088,8 @@ def _validate_responses_conversation_controls(body: dict[str, Any]) -> None: yields opaque 400s; named unsupported errors let buyers migrate cleanly. Explicit JSON null or empty string for string fields is treat-as-omit (SDK optional default). Empty include/text structures remain omit no-ops. + ``truncation`` values ``auto`` and ``disabled`` are also omit-equivalent + no-ops: without conversation state there is nothing to truncate. """ def _present_nonempty(value: Any) -> bool: if value is None: @@ -1107,12 +1110,22 @@ def _present_nonempty(value: Any) -> bool: "invalid_conversation", "conversation is not supported on /v1/responses", ) - if "truncation" in body and _present_nonempty(body.get("truncation")): - raise RequestError( - 400, - "invalid_truncation", - "truncation is not supported on /v1/responses", - ) + if "truncation" in body: + truncation = body.get("truncation") + # Explicit JSON null / empty-whitespace string: omit no-op. + if truncation is None or (isinstance(truncation, str) and not truncation.strip()): + pass + elif isinstance(truncation, str) and truncation.strip() in {"auto", "disabled"}: + # OpenAI enum. Without previous_response_id/conversation there is no + # multi-turn context to truncate, so auto|disabled are honest + # omit-equivalent no-ops (SDK clients often send truncation=auto). + pass + else: + raise RequestError( + 400, + "invalid_truncation", + "truncation must be auto or disabled on /v1/responses", + ) if "include" in body: include = body.get("include") # Explicit JSON null, empty array, or empty/whitespace string is treat-as-omit. @@ -1618,13 +1631,17 @@ def _validate_completions_tools_surface(body: dict[str, Any]) -> None: parallel_present = False elif "parallel_tool_calls" in body: # true or non-boolean — surface as tools unsupported (or type error below). - if not isinstance(parallel, bool): + # Explicit JSON null or empty/whitespace string is treat-as-omit. + if parallel is None or (isinstance(parallel, str) and not parallel.strip()): + parallel_present = False + elif not isinstance(parallel, bool): raise RequestError( 400, "invalid_parallel_tool_calls", "parallel_tool_calls must be a boolean", ) - parallel_present = True + else: + parallel_present = True else: parallel_present = False @@ -3208,9 +3225,12 @@ def do_POST(self) -> None: # noqa: N802 if "parallel_tool_calls" in body: # Always type-check. With tools, true/false both valid for # provider passthrough; without tools, true fails closed. - # Explicit JSON null is treat-as-omit (SDK optional default). + # Explicit JSON null or empty/whitespace string is treat-as-omit + # (SDK optional default / stringified empty control). ptc = body.get("parallel_tool_calls") - if ptc is not None: + if ptc is not None and not ( + isinstance(ptc, str) and not ptc.strip() + ): if not isinstance(ptc, bool): raise RequestError( 400, diff --git a/tests/test_responses_conversation_controls_http_honesty.py b/tests/test_responses_conversation_controls_http_honesty.py index 9c1cfb55f..01c0e7be2 100644 --- a/tests/test_responses_conversation_controls_http_honesty.py +++ b/tests/test_responses_conversation_controls_http_honesty.py @@ -98,7 +98,26 @@ def test_http_responses_rejects_conversation() -> None: thread.join(timeout=5) -def test_http_responses_rejects_truncation() -> None: +def test_http_responses_accepts_truncation_auto_and_disabled() -> None: + """auto|disabled are omit-equivalent no-ops without conversation state.""" + server, thread, port = _server() + try: + for value in ("auto", "disabled", " auto ", "disabled"): + status, body = _post( + port, + { + "model": "mock-planner", + "input": "hello truncation", + "truncation": value, + }, + ) + assert status == 200, (value, body) + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_responses_rejects_unknown_truncation() -> None: server, thread, port = _server() try: status, body = _post( @@ -106,7 +125,7 @@ def test_http_responses_rejects_truncation() -> None: { "model": "mock-planner", "input": "hello truncation", - "truncation": "auto", + "truncation": "drop_middle", }, ) assert status == 400, body @@ -156,7 +175,8 @@ def test_http_responses_rejects_text_control() -> None: test_http_responses_accepts_baseline_without_conversation_controls() test_http_responses_rejects_previous_response_id() test_http_responses_rejects_conversation() - test_http_responses_rejects_truncation() + test_http_responses_accepts_truncation_auto_and_disabled() + test_http_responses_rejects_unknown_truncation() test_http_responses_rejects_include() test_http_responses_rejects_text_control() print("ok") diff --git a/tests/test_responses_truncation_parallel_empty_noop_http_honesty.py b/tests/test_responses_truncation_parallel_empty_noop_http_honesty.py new file mode 100644 index 000000000..f7d426f3b --- /dev/null +++ b/tests/test_responses_truncation_parallel_empty_noop_http_honesty.py @@ -0,0 +1,196 @@ +"""Responses truncation auto|disabled and empty-string parallel_tool_calls no-ops over HTTP.""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from contextual_orchestrator import ModelAgent, TaskOrchestrator # noqa: E402 +from contextual_orchestrator.server import SecurityConfig, build_server # noqa: E402 + +_TEST_AUTH_TOKEN = "responses_truncation_parallel_empty_noop_http_honesty_token" # noqa: S105 + + +def build() -> TaskOrchestrator: + return TaskOrchestrator( + [ModelAgent("general_agent", "mock-planner", tags=("reasoning", "writing"))] + ) + + +def _post(path: str, port: int, payload: dict) -> tuple[int, dict]: + request = urllib.request.Request( + f"http://127.0.0.1:{port}{path}", + data=json.dumps(payload).encode("utf-8"), + headers={ + "content-type": "application/json", + "authorization": f"Bearer {_TEST_AUTH_TOKEN}", + "connection": "close", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode("utf-8")) + + +def _server(): + server = build_server(build(), port=0, security=SecurityConfig(auth_token=_TEST_AUTH_TOKEN)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, server.server_address[1] + + +def test_http_responses_truncation_auto_disabled_noop() -> None: + server, thread, port = _server() + try: + for truncation in ("auto", "disabled", None, "", " "): + payload: dict = {"model": "mock-planner", "input": "truncation noop"} + if truncation is not None or truncation == "": + # Always include key for "" / whitespace; skip only when intentionally omitted. + pass + if truncation is not None: + payload["truncation"] = truncation + # For None we still send the key as JSON null. + if truncation is None: + payload["truncation"] = None + status, body = _post("/v1/responses", port, payload) + assert status == 200, (truncation, body) + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_responses_rejects_non_enum_truncation() -> None: + server, thread, port = _server() + try: + status, body = _post( + "/v1/responses", + port, + { + "model": "mock-planner", + "input": "bad truncation", + "truncation": "aggressive", + }, + ) + assert status == 400, body + blob = json.dumps(body) + assert "invalid_truncation" in blob + assert "unknown_fields" not in blob + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_responses_still_rejects_previous_response_id() -> None: + """State-changing conversation controls remain fail-closed with named errors.""" + server, thread, port = _server() + try: + status, body = _post( + "/v1/responses", + port, + { + "model": "mock-planner", + "input": "prev", + "previous_response_id": "resp_x", + "truncation": "auto", + }, + ) + assert status == 400, body + assert "invalid_previous_response_id" in json.dumps(body) + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_chat_parallel_tool_calls_empty_string_noop() -> None: + server, thread, port = _server() + try: + for value in ("", " ", None): + status, body = _post( + "/v1/chat/completions", + port, + { + "model": "mock-planner", + "messages": [{"role": "user", "content": "ptc empty"}], + "parallel_tool_calls": value, + }, + ) + assert status == 200, (value, body) + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_responses_parallel_tool_calls_empty_string_noop() -> None: + server, thread, port = _server() + try: + for value in ("", " ", None, False): + status, body = _post( + "/v1/responses", + port, + { + "model": "mock-planner", + "input": "ptc empty", + "parallel_tool_calls": value, + }, + ) + assert status == 200, (value, body) + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_chat_parallel_tool_calls_true_without_tools_still_fail_closed() -> None: + server, thread, port = _server() + try: + status, body = _post( + "/v1/chat/completions", + port, + { + "model": "mock-planner", + "messages": [{"role": "user", "content": "ptc true"}], + "parallel_tool_calls": True, + }, + ) + assert status == 400, body + assert "invalid_parallel_tool_calls" in json.dumps(body) + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_completions_parallel_tool_calls_empty_string_noop() -> None: + server, thread, port = _server() + try: + status, body = _post( + "/v1/completions", + port, + { + "model": "mock-planner", + "prompt": "ptc empty completions", + "parallel_tool_calls": "", + }, + ) + assert status == 200, body + finally: + server.shutdown() + thread.join(timeout=5) + + +if __name__ == "__main__": + test_http_responses_truncation_auto_disabled_noop() + test_http_responses_rejects_non_enum_truncation() + test_http_responses_still_rejects_previous_response_id() + test_http_chat_parallel_tool_calls_empty_string_noop() + test_http_responses_parallel_tool_calls_empty_string_noop() + test_http_chat_parallel_tool_calls_true_without_tools_still_fail_closed() + test_http_completions_parallel_tool_calls_empty_string_noop() + print("ok")