diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index cd39905cf..e62ab912a 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -5741,25 +5741,7 @@ def do_POST(self) -> None: # noqa: N802 tool_loop_header = self.headers.get( "x-contextual-orchestrator-tool-loop", "" ).strip().lower() - if tools_list and tool_loop_header == "v1": - # The Responses client owns execution of the returned - # function calls; the gateway preserves the full shape. - started_at = time.perf_counter() - raw_response = self._run( - lambda: orchestrator.proxy_completion(body, endpoint="responses") - ) - orchestrator.record_analytics_event( - "responses_tool_passthrough", - { - "endpoint_path": "/v1/responses", - "actor_scope": "inference", - "status_code": 200, - "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), - }, - ) - self._send(raw_response) - return - if tools_list: + if tools_list and tool_loop_header != "v1": raise RequestError( 422, "multi_agent_tools_unsupported", @@ -5824,6 +5806,25 @@ def do_POST(self) -> None: # noqa: N802 "invalid_stream", "stream is not supported on /v1/responses", ) + if tools_list and tool_loop_header == "v1": + # Validate input and stream before passthrough so the + # client-owned contract cannot silently downgrade a + # requested stream or accept a missing input. + started_at = time.perf_counter() + raw_response = self._run( + lambda: orchestrator.proxy_completion(body, endpoint="responses") + ) + orchestrator.record_analytics_event( + "responses_tool_passthrough", + { + "endpoint_path": "/v1/responses", + "actor_scope": "inference", + "status_code": 200, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + }, + ) + self._send(raw_response) + return response_contract: dict[str, Any] | None = None raw_response_format = body.get("response_format") if isinstance(raw_response_format, dict) and raw_response_format.get("type") in { @@ -5983,14 +5984,21 @@ def _read_json(self) -> dict[str, Any]: if timeout_supported: previous_timeout = connection.gettimeout() connection.settimeout(security.request_read_timeout_seconds) + read_deadline = time.monotonic() + security.request_read_timeout_seconds try: chunks = bytearray() while len(chunks) < body_size: + if time.monotonic() >= read_deadline: + self.close_connection = True + raise RequestError(408, "request_read_timeout", "request body read timed out") chunk = self.rfile.read(body_size - len(chunks)) if not chunk: self.close_connection = True raise RequestError(400, "invalid_request_framing", "request body ended before content-length") chunks.extend(chunk) + if time.monotonic() >= read_deadline: + self.close_connection = True + raise RequestError(408, "request_read_timeout", "request body read timed out") except (TimeoutError, socket.timeout): self.close_connection = True raise RequestError(408, "request_read_timeout", "request body read timed out") from None diff --git a/tests/test_chat_parallel_tool_calls_http_honesty.py b/tests/test_chat_parallel_tool_calls_http_honesty.py index 051ad20f9..9760da0f4 100644 --- a/tests/test_chat_parallel_tool_calls_http_honesty.py +++ b/tests/test_chat_parallel_tool_calls_http_honesty.py @@ -135,11 +135,3 @@ def test_http_chat_parallel_tool_calls_true_rejects_single_agent_fallback() -> N finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_chat_parallel_tool_calls_false_without_tools_ok() - test_http_chat_parallel_tool_calls_true_without_tools_fail_closed() - test_http_chat_parallel_tool_calls_non_boolean_fail_closed() - test_http_chat_parallel_tool_calls_true_with_tools_passthrough() - print("ok") diff --git a/tests/test_chat_tools_shape_http_honesty.py b/tests/test_chat_tools_shape_http_honesty.py index 526b7baee..8c054a044 100644 --- a/tests/test_chat_tools_shape_http_honesty.py +++ b/tests/test_chat_tools_shape_http_honesty.py @@ -231,15 +231,3 @@ def test_http_chat_accepts_tools_omitted() -> None: finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_chat_accepts_valid_function_tools() - test_http_chat_rejects_empty_tools_array() - test_http_chat_rejects_tool_type_not_function() - test_http_chat_rejects_tool_missing_function_name() - test_http_chat_rejects_tool_function_name_bad_charset() - test_http_chat_rejects_tool_sibling_unknown_fields() - test_http_chat_rejects_parameters_non_object() - test_http_chat_accepts_tools_omitted() - print("ok") diff --git a/tests/test_functions_null_max_tool_calls_null_http_honesty.py b/tests/test_functions_null_max_tool_calls_null_http_honesty.py index a2a26bf71..6d7007dca 100644 --- a/tests/test_functions_null_max_tool_calls_null_http_honesty.py +++ b/tests/test_functions_null_max_tool_calls_null_http_honesty.py @@ -103,7 +103,6 @@ def test_http_responses_accepts_null_max_tool_calls_and_functions() -> None: "max_tool_calls": None, "functions": None, "function_call": None, - "functions": [], }, ) assert status == 200, body diff --git a/tests/test_responses_flat_tools_http_honesty.py b/tests/test_responses_flat_tools_http_honesty.py index df162a038..27352b247 100644 --- a/tests/test_responses_flat_tools_http_honesty.py +++ b/tests/test_responses_flat_tools_http_honesty.py @@ -185,12 +185,3 @@ def test_http_tools_rejects_mixed_nested_and_flat() -> None: finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_responses_accepts_flat_function_tools() - test_http_responses_accepts_flat_tools_with_tool_choice_name() - test_http_chat_still_accepts_nested_function_tools() - test_http_chat_accepts_flat_function_tools_too() - test_http_tools_rejects_mixed_nested_and_flat() - print("ok") diff --git a/tests/test_responses_logit_bias_logprobs_http_honesty.py b/tests/test_responses_logit_bias_logprobs_http_honesty.py index 12c5b6020..7f3911051 100644 --- a/tests/test_responses_logit_bias_logprobs_http_honesty.py +++ b/tests/test_responses_logit_bias_logprobs_http_honesty.py @@ -165,14 +165,3 @@ def test_http_responses_rejects_non_boolean_logprobs() -> None: finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_responses_accepts_empty_and_valid_logit_bias() - test_http_responses_rejects_non_digit_logit_bias_key() - test_http_responses_rejects_out_of_range_logit_bias_value() - test_http_responses_accepts_logprobs_false() - test_http_responses_rejects_unapplied_logprobs_with_top_logprobs() - test_http_responses_rejects_top_logprobs_without_logprobs() - test_http_responses_rejects_non_boolean_logprobs() - print("ok") diff --git a/tests/test_responses_tools_shape_http_honesty.py b/tests/test_responses_tools_shape_http_honesty.py index a15122009..9b34eef30 100644 --- a/tests/test_responses_tools_shape_http_honesty.py +++ b/tests/test_responses_tools_shape_http_honesty.py @@ -104,6 +104,43 @@ def test_http_responses_preserves_tools_with_explicit_loop_header() -> None: thread.join(timeout=5) +def test_http_responses_tool_loop_rejects_stream_true() -> None: + """Client-owned Responses tool loops must reject unsupported streaming.""" + server, thread, port = _server() + try: + status, body = _post( + port, + { + "model": "mock-planner", + "input": "stream tools", + "tools": _valid_tools(), + "stream": True, + }, + tool_loop=True, + ) + assert status == 400, body + assert body["error"]["code"] == "invalid_stream" + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_http_responses_tool_loop_requires_input() -> None: + """Client-owned Responses tool loops still require a non-empty input.""" + server, thread, port = _server() + try: + status, body = _post( + port, + {"model": "mock-planner", "tools": _valid_tools()}, + tool_loop=True, + ) + assert status == 400, body + assert body["error"]["code"] == "invalid_input" + finally: + server.shutdown() + thread.join(timeout=5) + + def test_http_responses_accepts_empty_tools_array_as_noop() -> None: """SDKs often send tools: [] when no tools are configured — honest no-op.""" server, thread, port = _server() diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index 6aa57bf5c..18471581c 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os import socket import threading import urllib.error diff --git a/tests/test_whole_float_string_int_coerce_http_honesty.py b/tests/test_whole_float_string_int_coerce_http_honesty.py index f73a67ed6..cfc3c3d6c 100644 --- a/tests/test_whole_float_string_int_coerce_http_honesty.py +++ b/tests/test_whole_float_string_int_coerce_http_honesty.py @@ -221,16 +221,3 @@ def test_http_responses_rejects_unapplied_top_logprobs_whole_float_string() -> N finally: server.shutdown() thread.join(timeout=5) - - -if __name__ == "__main__": - test_http_chat_accepts_n_whole_float_string() - test_http_responses_accepts_seed_whole_float_string() - test_http_chat_rejects_fractional_float_string_n() - test_http_chat_accepts_top_logprobs_zero_float_strings() - test_http_chat_still_rejects_nonzero_top_logprobs_float_string() - test_http_completions_accepts_top_logprobs_zero_float_string() - test_http_completions_accepts_best_of_whole_float_string() - test_http_chat_accepts_max_tool_calls_zero_float_string() - test_http_responses_rejects_unapplied_top_logprobs_whole_float_string() - print("ok")