Skip to content

Commit a612f0a

Browse files
committed
feat!: reject legacy RootModel envelope in function parameters
The updated bare dict for OpenAI API compatibility would still accept a key named RootModel, but the behavior would be wrong. To assist in migration add validation and reject the legacy wrapper. BREAKING CHANGE: FunctionParameters validator now rejects {"RootModel": {...}} envelope pattern. Send parameters as bare JSON Schema objects instead. Signed-off-by: Mark Sturdevant <mark.sturdevant@ibm.com> Assisted-by: Mark Sturdevant <mark.sturdevant@ibm.com>
1 parent 2bff125 commit a612f0a

3 files changed

Lines changed: 103 additions & 1 deletion

File tree

cli/serve/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ class FunctionParameters(RootModel[dict[str, Any]]):
2222

2323
root: dict[str, Any]
2424

25+
@model_validator(mode="after")
26+
def _reject_legacy_envelope(self) -> "FunctionParameters":
27+
"""Reject legacy RootModel envelope pattern.
28+
29+
Ensures parameters are sent as a bare JSON Schema object, not wrapped
30+
in a {"RootModel": {...}} envelope which would be invalid.
31+
"""
32+
if set(self.root.keys()) == {"RootModel"}:
33+
raise ValueError(
34+
"Legacy {'RootModel': {...}} envelope is no longer accepted. "
35+
"Send parameters as a bare JSON Schema object."
36+
)
37+
return self
38+
2539

2640
class FunctionDefinition(BaseModel):
2741
name: str

test/cli/test_serve_integration.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,43 @@ def test_server_error_returns_500(self, client, mock_module):
571571
assert data["error"]["type"] == "server_error"
572572
assert "Internal error" not in data["error"]["message"]
573573
assert "Internal server error" in data["error"]["message"]
574+
575+
def test_legacy_root_model_envelope_rejected_via_http(self, client, mock_module):
576+
"""Test that legacy {'RootModel': {...}} envelope is rejected at HTTP layer.
577+
578+
Verifies that the FunctionParameters validator catches the legacy envelope
579+
pattern and returns a proper 400 error via the HTTP API.
580+
"""
581+
# Send request with legacy envelope in function parameters
582+
response = client.post(
583+
"/v1/chat/completions",
584+
json={
585+
"model": "test-model",
586+
"messages": [{"role": "user", "content": "What's the weather?"}],
587+
"tools": [
588+
{
589+
"type": "function",
590+
"function": {
591+
"name": "get_weather",
592+
"description": "Get weather",
593+
"parameters": {
594+
"RootModel": {
595+
"type": "object",
596+
"properties": {"location": {"type": "string"}},
597+
}
598+
},
599+
},
600+
}
601+
],
602+
},
603+
)
604+
605+
# Should return 400 with validation error
606+
assert response.status_code == 400
607+
data = response.json()
608+
assert "error" in data
609+
assert data["error"]["type"] == "invalid_request_error"
610+
assert (
611+
"Legacy {'RootModel': {...}} envelope is no longer accepted"
612+
in data["error"]["message"]
613+
)

test/cli/test_serve_models.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from pydantic import ValidationError
55

6-
from cli.serve.models import StreamOptions
6+
from cli.serve.models import FunctionParameters, StreamOptions
77

88

99
class TestStreamOptions:
@@ -79,3 +79,51 @@ def test_model_dump_json_serialization(self):
7979
json_str = options.model_dump_json()
8080
assert "include_usage" in json_str
8181
assert "true" in json_str.lower()
82+
83+
84+
class TestFunctionParameters:
85+
"""Tests for the FunctionParameters RootModel validator."""
86+
87+
def test_valid_json_schema_accepted(self):
88+
"""Test that a valid JSON Schema dict is accepted."""
89+
schema = {
90+
"type": "object",
91+
"properties": {"location": {"type": "string"}},
92+
"required": ["location"],
93+
}
94+
params = FunctionParameters(root=schema)
95+
assert params.root == schema
96+
97+
def test_legacy_root_model_envelope_rejected(self):
98+
"""Test that legacy {'RootModel': {...}} envelope is rejected."""
99+
legacy_envelope = {
100+
"RootModel": {
101+
"type": "object",
102+
"properties": {"location": {"type": "string"}},
103+
}
104+
}
105+
with pytest.raises(ValidationError) as exc_info:
106+
FunctionParameters(root=legacy_envelope)
107+
108+
errors = exc_info.value.errors()
109+
assert len(errors) == 1
110+
error_msg = str(exc_info.value)
111+
assert "Legacy {'RootModel': {...}} envelope is no longer accepted" in error_msg
112+
113+
def test_root_model_with_additional_keys_accepted(self):
114+
"""Test that a dict with 'RootModel' plus other keys is accepted."""
115+
# This is a valid schema that happens to have a property named "RootModel"
116+
schema = {
117+
"type": "object",
118+
"properties": {
119+
"RootModel": {"type": "string"},
120+
"other_field": {"type": "number"},
121+
},
122+
}
123+
params = FunctionParameters(root=schema)
124+
assert params.root == schema
125+
126+
def test_empty_dict_accepted(self):
127+
"""Test that an empty dict is accepted (though not a useful schema)."""
128+
params = FunctionParameters(root={})
129+
assert params.root == {}

0 commit comments

Comments
 (0)