Skip to content
Merged
46 changes: 27 additions & 19 deletions contextual_orchestrator/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Comment on lines +5999 to +6001

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Fully received requests can be rejected as timed out

A complete request body is rejected as timed out (raise RequestError(408,...) at contextual_orchestrator/server.py:5999-6001) after the final chunk that completes the body is read, so a valid, fully-received request is thrown away as an error.
Impact: On a slow connection whose last byte arrives right around the deadline, a request whose entire body was already received is returned a 408 error instead of being processed.

Deadline re-check runs even once the body is complete

The loop condition is while len(chunks) < body_size (contextual_orchestrator/server.py:5990). The deadline check at the top of the loop (5991-5993) correctly only fires when more data is still needed. However, the second check added after chunks.extend(chunk) (5999-6001) runs unconditionally after each read — including the read that completes the body. If time.monotonic() >= read_deadline at that moment, a 408 is raised even though len(chunks) == body_size and the full body is available in chunks. The top-of-loop check already covers the incomplete case on the next iteration, so the post-extend check only adds the spurious rejection of an already-complete body. Guarding it with len(chunks) < body_size avoids discarding a fully-read request.

Suggested change
if time.monotonic() >= read_deadline:
self.close_connection = True
raise RequestError(408, "request_read_timeout", "request body read timed out")
if len(chunks) < body_size and time.monotonic() >= read_deadline:
self.close_connection = True
raise RequestError(408, "request_read_timeout", "request body read timed out")
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

except (TimeoutError, socket.timeout):
self.close_connection = True
raise RequestError(408, "request_read_timeout", "request body read timed out") from None
Expand Down
8 changes: 0 additions & 8 deletions tests/test_chat_parallel_tool_calls_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
12 changes: 0 additions & 12 deletions tests/test_chat_tools_shape_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 0 additions & 9 deletions tests/test_responses_flat_tools_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
11 changes: 0 additions & 11 deletions tests/test_responses_logit_bias_logprobs_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
37 changes: 37 additions & 0 deletions tests/test_responses_tools_shape_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion tests/test_security_hardening.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import json
import os
import socket
import threading
import urllib.error
Expand Down
13 changes: 0 additions & 13 deletions tests/test_whole_float_string_int_coerce_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")