From 25d9cb1c7dec8cefdbe7391dcad62f5af7d9ab15 Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 14 Oct 2026 18:40:13 +0200 Subject: [PATCH] Gateway: drop strict on a tool whose schema cannot meet strict mode instead of failing the call with 400 Chat clients such as AIRI send strict: true with looser schemas; the Responses upstream answers 400 unless every object has additionalProperties false and every property is required. The transpiler now downgrades that tool to non-strict and reports it in the warnings list. Co-Authored-By: Claude Sonnet 5.5 --- .../services/openai_gateway/responses.py | 39 +++++++++++++ pyproject.toml | 6 +- .../unit/services/openai_gateway/responses.py | 55 ++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/devplacepy/services/openai_gateway/responses.py b/devplacepy/services/openai_gateway/responses.py index 4455edcd..3f6ba8e1 100644 --- a/devplacepy/services/openai_gateway/responses.py +++ b/devplacepy/services/openai_gateway/responses.py @@ -151,6 +151,35 @@ def _chat_messages_to_input(messages: Any) -> tuple[str, list, list]: return "\n\n".join(instructions), items, warn +def _strict_compatible(schema: Any) -> bool: + """True when every object in the JSON schema meets the strict-mode rules: + `additionalProperties` is false and every property is listed in `required`. + """ + if not isinstance(schema, dict): + return True + if schema.get("type") == "object" or "properties" in schema: + props = schema.get("properties") or {} + if schema.get("additionalProperties") is not False: + return False + if set(props) - set(schema.get("required") or []): + return False + for key in ("properties", "$defs", "definitions"): + children = schema.get(key) + if isinstance(children, dict) and not all( + _strict_compatible(child) for child in children.values() + ): + return False + for key in ("items", "anyOf", "oneOf", "allOf"): + children = schema.get(key) + if isinstance(children, dict): + children = [children] + if isinstance(children, list) and not all( + _strict_compatible(child) for child in children + ): + return False + return True + + def _chat_tool_to_responses(tool: Any, warn: list) -> Optional[dict]: if not isinstance(tool, dict): return None @@ -167,6 +196,16 @@ def _chat_tool_to_responses(tool: Any, warn: list) -> Optional[dict]: # Chat is non-strict by default; Responses attempts strict mode when # the key is omitted, so pin the Chat default explicitly. out["strict"] = bool(func.get("strict", False)) + if out["strict"] and not _strict_compatible(out["parameters"]): + # The upstream answers 400 to a strict tool whose schema has an + # object without `additionalProperties: false` or a property + # missing from `required` (measured 2026-10-14), failing the whole + # call. Chat clients such as AIRI send strict with looser schemas. + out["strict"] = False + warn.append( + "tool %r: dropped strict (schema is not strict-compatible)" + % (out["name"],) + ) return out warn.append( "dropped tool type %r (only function tools transpile)" diff --git a/pyproject.toml b/pyproject.toml index 68921454..17aa352c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devplacepy" -version = "1.0.21" +version = "1.1.1" description = "DevPlace - The Developer Social Network" requires-python = ">=3.12" dependencies = [ @@ -35,6 +35,7 @@ dependencies = [ "curl_cffi", "faker", "defusedxml", + "stealthii @ git+https://retoor.molodetz.nl/retoor/stealthii.git", ] [project.scripts] @@ -50,6 +51,9 @@ bots = ["playwright", "faker"] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.pytest.ini_options] testpaths = ["tests/unit", "tests/api", "tests/e2e"] python_files = ["*.py"] diff --git a/tests/unit/services/openai_gateway/responses.py b/tests/unit/services/openai_gateway/responses.py index c9ec3ba2..9f96e03c 100644 --- a/tests/unit/services/openai_gateway/responses.py +++ b/tests/unit/services/openai_gateway/responses.py @@ -83,7 +83,11 @@ def test_chat_to_responses_pins_strict_explicitly(): "function": { "name": "f", "strict": True, - "parameters": {"type": "object"}, + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, }, } ], @@ -92,6 +96,55 @@ def test_chat_to_responses_pins_strict_explicitly(): assert resp["tools"][0]["strict"] is True +def test_chat_to_responses_drops_strict_the_schema_cannot_meet(): + def tool(parameters): + return { + "type": "function", + "function": {"name": "f", "strict": True, "parameters": parameters}, + } + + nested_open = { + "type": "object", + "properties": { + "words": { + "type": "array", + "items": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + } + }, + "required": ["words"], + "additionalProperties": False, + } + optional_property = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": False, + } + for parameters in (nested_open, optional_property, {"type": "object"}): + resp, warn = chat_to_responses( + { + "model": "m", + "messages": [{"role": "user", "content": "hi"}], + "tools": [tool(parameters)], + } + ) + assert resp["tools"][0]["strict"] is False + assert any("dropped strict" in w for w in warn) + nested_open["properties"]["words"]["items"]["additionalProperties"] = False + resp, warn = chat_to_responses( + { + "model": "m", + "messages": [{"role": "user", "content": "hi"}], + "tools": [tool(nested_open)], + } + ) + assert resp["tools"][0]["strict"] is True + assert not any("dropped strict" in w for w in warn) + + def test_chat_to_responses_warns_on_drops_and_unknowns(): resp, warn = chat_to_responses( _chat_body(n=2, stop=["x"], seed=1, wat_field=True)