Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions contextual_orchestrator/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
26 changes: 23 additions & 3 deletions tests/test_responses_conversation_controls_http_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,34 @@ 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(
port,
{
"model": "mock-planner",
"input": "hello truncation",
"truncation": "auto",
"truncation": "drop_middle",
},
)
assert status == 400, body
Expand Down Expand Up @@ -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")
196 changes: 196 additions & 0 deletions tests/test_responses_truncation_parallel_empty_noop_http_honesty.py
Original file line number Diff line number Diff line change
@@ -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")
Loading