diff --git a/tests/tools/test_schema_sanitizer.py b/tests/tools/test_schema_sanitizer.py index cc54fbfeb0..89fbcd91d2 100644 --- a/tests/tools/test_schema_sanitizer.py +++ b/tests/tools/test_schema_sanitizer.py @@ -302,3 +302,61 @@ def test_strip_none_returns_zero(): tools, stripped = strip_pattern_and_format(None) assert tools is None assert stripped == 0 + + +def test_top_level_allof_stripped_for_codex_backend_compat(): + """OpenAI Codex backend rejects top-level allOf/oneOf/anyOf/enum/not.""" + tools = [_tool("memory", { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["add", "replace"]}, + "content": {"type": "string"}, + }, + "required": ["action"], + "allOf": [ + { + "if": {"properties": {"action": {"const": "add"}}, "required": ["action"]}, + "then": {"required": ["content"]}, + }, + ], + })] + out = sanitize_tool_schemas(tools) + params = out[0]["function"]["parameters"] + assert "allOf" not in params + # Properties and required survive. + assert params["required"] == ["action"] + assert "content" in params["properties"] + + +def test_top_level_oneof_anyof_enum_not_stripped(): + """All five forbidden top-level combinators are dropped.""" + tools = [_tool("t", { + "type": "object", + "properties": {"x": {"type": "string"}}, + "oneOf": [{"required": ["x"]}], + "anyOf": [{"required": ["x"]}], + "enum": ["bogus-top-level"], + "not": {"required": ["y"]}, + })] + out = sanitize_tool_schemas(tools) + params = out[0]["function"]["parameters"] + for key in ("oneOf", "anyOf", "enum", "not"): + assert key not in params, f"{key} should be stripped from top level" + + +def test_nested_allof_preserved(): + """Combinators inside a property's schema are preserved (only top is strict).""" + tools = [_tool("t", { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": {"mode": {"type": "string"}}, + "allOf": [{"required": ["mode"]}], + }, + }, + })] + out = sanitize_tool_schemas(tools) + nested = out[0]["function"]["parameters"]["properties"]["config"] + assert "allOf" in nested + assert nested["allOf"] == [{"required": ["mode"]}] diff --git a/tools/schema_sanitizer.py b/tools/schema_sanitizer.py index 8c0a915aca..87587c7fed 100644 --- a/tools/schema_sanitizer.py +++ b/tools/schema_sanitizer.py @@ -84,6 +84,47 @@ def _sanitize_single_tool(tool: dict) -> dict: # argument coercion (``model_tools._schema_allows_null``) can still # map a model-emitted ``"null"`` string to Python ``None``. fn["parameters"] = strip_nullable_unions(fn["parameters"], keep_nullable_hint=True) + # Strip top-level combinators that strict backends (OpenAI's Codex + # endpoint at chatgpt.com/backend-api/codex) reject outright. Nested + # combinators inside properties are preserved. + fn["parameters"] = _strip_top_level_combinators( + fn["parameters"], path=fn.get("name", "") + ) + return out + + +_TOP_LEVEL_FORBIDDEN_KEYS = ("allOf", "anyOf", "oneOf", "enum", "not") + + +def _strip_top_level_combinators(params: dict, *, path: str = "") -> dict: + """Drop combinator keywords from the top-level of a function parameters schema. + + OpenAI's Codex backend (``chatgpt.com/backend-api/codex``) is stricter + than the public Functions API and rejects requests with:: + + Invalid schema for function 'X': schema must have type 'object' and + not have 'oneOf'/'anyOf'/'allOf'/'enum'/'not' at the top level. + + These keywords are typically used for conditional required-fields hints + (``allOf: [{if: ..., then: {required: [...]}}]``). Removing them at the + top level discards the hint but does not change which argument *values* + are valid — the tool handler always re-validates required fields. + + Only the *top* level is stripped; combinators nested inside a property's + schema are preserved (the strict rule only applies to the outermost + parameters object). + """ + if not isinstance(params, dict): + return params + out = dict(params) + for key in _TOP_LEVEL_FORBIDDEN_KEYS: + if key in out: + logger.debug( + "schema_sanitizer[%s]: stripped top-level %r combinator " + "from tool parameters (strict-backend compat)", + path, key, + ) + out.pop(key, None) return out