Skip to content

Commit 933b71a

Browse files
authored
fix: make FunctionParameters and LogitBias OpenAI-compatible (#1039)
* fix: make FunctionParameters and LogitBias OpenAI-compatible Use Pydantic's RootModel to accept bare JSON Schema dicts instead of requiring a non-standard RootModel wrapper field. This makes m serve compatible with standard OpenAI clients. BREAKING CHANGE: FunctionParameters(RootModel={...}) is now FunctionParameters({...}) Signed-off-by: Mark Sturdevant <mark.sturdevant@ibm.com> Assisted-by: IBM Bob * test(cli): update tests and example removing RootModel Signed-off-by: Mark Sturdevant <mark.sturdevant@ibm.com> * chore: remove unused LogitBias model Signed-off-by: Mark Sturdevant <mark.sturdevant@ibm.com> * 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> * fix(serve): serialize JsonSchemaFormat with 'schema' alias Add serialize_by_alias=True to JsonSchemaFormat.model_config to ensure schema_ field serializes as "schema" not "schema_" for OpenAI compatibility. Add test coverage for serialization behavior. Signed-off-by: Mark Sturdevant <mark.sturdevant@ibm.com> Assisted-by: IBM Bob --------- Signed-off-by: Mark Sturdevant <mark.sturdevant@ibm.com>
1 parent e2e1b5d commit 933b71a

7 files changed

Lines changed: 247 additions & 63 deletions

File tree

cli/serve/models.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Literal
22

3-
from pydantic import BaseModel, Field, model_validator
3+
from pydantic import BaseModel, Field, RootModel, model_validator
44

55
from mellea.helpers.openai_compatible_helpers import CompletionUsage
66

@@ -13,9 +13,28 @@ class ChatMessage(BaseModel):
1313
function_call: dict[str, Any] | None = None # For function/tool messages
1414

1515

16-
class FunctionParameters(BaseModel):
17-
# Accept any structure for function parameters
18-
RootModel: dict[str, Any]
16+
class FunctionParameters(RootModel[dict[str, Any]]):
17+
"""OpenAI-compatible function parameters as a bare JSON Schema object.
18+
19+
Accepts a standard JSON Schema dict directly without wrapping.
20+
Example: {"type": "object", "properties": {...}, "required": [...]}
21+
"""
22+
23+
root: dict[str, Any]
24+
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
1938

2039

2140
class FunctionDefinition(BaseModel):
@@ -41,7 +60,7 @@ class JsonSchemaFormat(BaseModel):
4160
strict: bool | None = None
4261
"""Accepted for OpenAI compatibility; currently ignored by ``m serve``."""
4362

44-
model_config = {"populate_by_name": True}
63+
model_config = {"populate_by_name": True, "serialize_by_alias": True}
4564

4665

4766
class ResponseFormat(BaseModel):
@@ -73,10 +92,6 @@ class StreamOptions(BaseModel):
7392
"""
7493

7594

76-
class LogitBias(BaseModel):
77-
RootModel: dict[str, float]
78-
79-
8095
class ChatCompletionRequest(BaseModel):
8196
model_config = {"extra": "allow"}
8297

docs/examples/m_serve/client_streaming_tool_calling.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,19 @@
2828
"name": "get_weather",
2929
"description": "Get the current weather in a given location",
3030
"parameters": {
31-
"RootModel": {
32-
"type": "object",
33-
"properties": {
34-
"location": {
35-
"type": "string",
36-
"description": "The city name, e.g. San Francisco",
37-
},
38-
"units": {
39-
"type": "string",
40-
"enum": ["celsius", "fahrenheit"],
41-
"description": "Temperature units",
42-
},
31+
"type": "object",
32+
"properties": {
33+
"location": {
34+
"type": "string",
35+
"description": "The city name, e.g. San Francisco",
4336
},
44-
"required": ["location"],
45-
}
37+
"units": {
38+
"type": "string",
39+
"enum": ["celsius", "fahrenheit"],
40+
"description": "Temperature units",
41+
},
42+
},
43+
"required": ["location"],
4644
},
4745
},
4846
},
@@ -52,16 +50,14 @@
5250
"name": "get_stock_price",
5351
"description": "Get the current stock price for a given ticker symbol",
5452
"parameters": {
55-
"RootModel": {
56-
"type": "object",
57-
"properties": {
58-
"symbol": {
59-
"type": "string",
60-
"description": "The stock ticker symbol, e.g. AAPL, GOOGL",
61-
}
62-
},
63-
"required": ["symbol"],
64-
}
53+
"type": "object",
54+
"properties": {
55+
"symbol": {
56+
"type": "string",
57+
"description": "The stock ticker symbol, e.g. AAPL, GOOGL",
58+
}
59+
},
60+
"required": ["symbol"],
6561
},
6662
},
6763
},
@@ -232,7 +228,9 @@ def main():
232228
# Example 4: Multi-turn conversation with tool use
233229
print("\n\n4. Multi-turn Conversation (Streaming)")
234230
print("-" * 60)
235-
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
231+
messages: list[dict[str, Any]] = [
232+
{"role": "user", "content": "What's the weather in Paris?"}
233+
]
236234

237235
print(f"User: {messages[0]['content']}")
238236
print("\nAssistant: ", end="", flush=True)

docs/examples/m_serve/client_tool_calling.py

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,19 @@
2727
"name": "get_weather",
2828
"description": "Get the current weather in a given location",
2929
"parameters": {
30-
"RootModel": {
31-
"type": "object",
32-
"properties": {
33-
"location": {
34-
"type": "string",
35-
"description": "The city name, e.g. San Francisco",
36-
},
37-
"units": {
38-
"type": "string",
39-
"enum": ["celsius", "fahrenheit"],
40-
"description": "Temperature units",
41-
},
30+
"type": "object",
31+
"properties": {
32+
"location": {
33+
"type": "string",
34+
"description": "The city name, e.g. San Francisco",
4235
},
43-
"required": ["location"],
44-
}
36+
"units": {
37+
"type": "string",
38+
"enum": ["celsius", "fahrenheit"],
39+
"description": "Temperature units",
40+
},
41+
},
42+
"required": ["location"],
4543
},
4644
},
4745
},
@@ -51,16 +49,14 @@
5149
"name": "get_stock_price",
5250
"description": "Get the current stock price for a given ticker symbol",
5351
"parameters": {
54-
"RootModel": {
55-
"type": "object",
56-
"properties": {
57-
"symbol": {
58-
"type": "string",
59-
"description": "The stock ticker symbol, e.g. AAPL, GOOGL",
60-
}
61-
},
62-
"required": ["symbol"],
63-
}
52+
"type": "object",
53+
"properties": {
54+
"symbol": {
55+
"type": "string",
56+
"description": "The stock ticker symbol, e.g. AAPL, GOOGL",
57+
}
58+
},
59+
"required": ["symbol"],
6460
},
6561
},
6662
},

test/cli/test_serve.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ async def test_tool_params_passed_to_model_options(self, mock_module):
462462
function=FunctionDefinition(
463463
name="test_func",
464464
description="A test function",
465-
parameters=FunctionParameters(RootModel={"type": "object"}),
465+
parameters=FunctionParameters({"type": "object"}),
466466
),
467467
)
468468
],
@@ -471,7 +471,7 @@ async def test_tool_params_passed_to_model_options(self, mock_module):
471471
FunctionDefinition(
472472
name="legacy_func",
473473
description="A legacy function",
474-
parameters=FunctionParameters(RootModel={"type": "object"}),
474+
parameters=FunctionParameters({"type": "object"}),
475475
)
476476
],
477477
function_call="auto",

test/cli/test_serve_integration.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def as_tool_function(self) -> ToolFunction:
6464
name="get_weather",
6565
description="Get the current weather in a location",
6666
parameters=FunctionParameters(
67-
RootModel={
67+
{
6868
"type": "object",
6969
"properties": {
7070
"location": {"type": "string", "description": "City name"},
@@ -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: 76 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, JsonSchemaFormat, StreamOptions
77

88

99
class TestStreamOptions:
@@ -79,3 +79,78 @@ 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 == {}
130+
131+
132+
class TestJsonSchemaFormat:
133+
"""Test JsonSchemaFormat serialization uses 'schema' alias, not 'schema_'."""
134+
135+
def test_serialization_uses_schema_alias(self):
136+
"""Verify schema_ serializes as 'schema' in dict and JSON output."""
137+
schema_def = {"type": "object", "properties": {"foo": {"type": "string"}}}
138+
json_schema = JsonSchemaFormat(name="TestSchema", schema=schema_def)
139+
140+
# Dict serialization
141+
dumped = json_schema.model_dump()
142+
assert "schema" in dumped and "schema_" not in dumped
143+
assert dumped["schema"] == schema_def
144+
145+
# JSON serialization
146+
json_str = json_schema.model_dump_json()
147+
assert '"schema":' in json_str and '"schema_":' not in json_str
148+
149+
# Input accepts both 'schema' (alias) and 'schema_' (field name)
150+
from_alias = JsonSchemaFormat(name="Test1", schema={"type": "string"})
151+
# Use model_validate to test runtime populate_by_name behavior (bypasses type checker)
152+
from_field = JsonSchemaFormat.model_validate(
153+
{"name": "Test2", "schema_": {"type": "number"}}
154+
)
155+
assert from_alias.schema_ == {"type": "string"}
156+
assert from_field.schema_ == {"type": "number"}

0 commit comments

Comments
 (0)