Skip to content

Commit 66b26cc

Browse files
authored
feat: support tool calls passthrough (#16)
1 parent 4b82bf1 commit 66b26cc

File tree

3 files changed

+88
-2
lines changed

3 files changed

+88
-2
lines changed

src/f5_ai_gateway_sdk/request_input.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@
77

88
from io import StringIO
99

10-
from pydantic import BaseModel
10+
from typing import Any, Self
11+
12+
from pydantic import (
13+
BaseModel,
14+
ConfigDict,
15+
field_serializer,
16+
PrivateAttr,
17+
)
18+
from pydantic.functional_validators import model_validator
1119

1220
from f5_ai_gateway_sdk.multipart_fields import INPUT_NAME
1321
from f5_ai_gateway_sdk.multipart_response import MultipartResponseField
@@ -40,9 +48,43 @@ class Message(BaseModel):
4048
"""
4149

4250
__autoclass_content__ = "class"
51+
model_config = ConfigDict(extra="allow")
4352

4453
content: str
4554
role: str = MessageRole.USER
55+
_content_parsed_as_null: bool = PrivateAttr(default=False)
56+
57+
# messages may have null content when
58+
# containing tool_calls
59+
# this tracks that case in order to allow
60+
# returning in the same format without the
61+
# SDK user needing to handle None on content
62+
@model_validator(mode="before")
63+
@classmethod
64+
def track_null_content(cls, data: Any) -> Any:
65+
if isinstance(data, dict) and data.get("content") is None:
66+
# Store this info in the data itself so it survives validation
67+
data["__content_parsed_as_null__"] = True
68+
data["content"] = ""
69+
return data
70+
71+
@model_validator(mode="after")
72+
def set_null_flag(self) -> Self:
73+
# Check if the original data indicated null content
74+
if hasattr(self, "__content_parsed_as_null__") or getattr(
75+
self, "__content_parsed_as_null__", False
76+
):
77+
self._content_parsed_as_null = True
78+
# Remove the temporary tracking field now that we've set the private attribute
79+
if hasattr(self, "__content_parsed_as_null__"):
80+
delattr(self, "__content_parsed_as_null__")
81+
return self
82+
83+
@field_serializer("content")
84+
def serialize_content(self, content: str):
85+
if self._content_parsed_as_null and len(content) == 0:
86+
return None
87+
return content
4688

4789

4890
class RequestInput(BaseModel):
@@ -68,6 +110,8 @@ class RequestInput(BaseModel):
68110
"""
69111

70112
__autoclass_content__ = "class"
113+
model_config = ConfigDict(extra="allow")
114+
71115
messages: list[Message]
72116

73117
def to_multipart_field(self) -> MultipartResponseField:

src/f5_ai_gateway_sdk/response_output.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
LICENSE file in the root directory of this source tree.
66
"""
77

8-
from pydantic import BaseModel
8+
from pydantic import BaseModel, ConfigDict
99

1010
from f5_ai_gateway_sdk.multipart_fields import RESPONSE_NAME
1111
from f5_ai_gateway_sdk.multipart_response import MultipartResponseField
@@ -21,6 +21,7 @@ class Choice(BaseModel):
2121
"""
2222

2323
__autoclass_content__ = "class"
24+
model_config = ConfigDict(extra="allow")
2425

2526
message: Message
2627

@@ -47,6 +48,7 @@ class ResponseOutput(BaseModel):
4748
"""
4849

4950
__autoclass_content__ = "class"
51+
model_config = ConfigDict(extra="allow")
5052

5153
choices: list[Choice]
5254
"""A list of ``Choice`` objects."""

tests/test_request_input.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Copyright (c) F5, Inc.
3+
4+
This source code is licensed under the Apache License Version 2.0 found in the
5+
LICENSE file in the root directory of this source tree.
6+
"""
7+
8+
from f5_ai_gateway_sdk.request_input import RequestInput
9+
import pytest
10+
11+
12+
@pytest.mark.parametrize(
13+
"content_value,should_have_null",
14+
[
15+
("null", True),
16+
('"Hello world"', False),
17+
],
18+
)
19+
def test_maintains_content_and_excludes_tracking_fields(
20+
content_value, should_have_null
21+
):
22+
"""
23+
Test that messages with both null and non-null content are properly handled
24+
during parsing and serialization, and that no internal tracking fields
25+
are exposed in the serialized result.
26+
27+
This verifies that the SDK properly handles:
28+
- Messages with null content (common in tool call scenarios)
29+
- Messages with regular string content
30+
- Additional fields (tool_calls) are persisted
31+
- Internal implementation details remain hidden from serialized output
32+
"""
33+
data = f'{{"messages":[{{"role":"user","content":{content_value},"tool_calls":[{{"id":"call_abc"}}]}}]}}'
34+
35+
parsed = RequestInput.model_validate_json(data)
36+
serialized = parsed.model_dump_json()
37+
38+
assert ("null" in serialized) == should_have_null
39+
assert "tool_calls" in serialized
40+
assert "content_parsed_as_null" not in serialized

0 commit comments

Comments
 (0)