-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Python: Normalize OpenAI tool-call argument envelopes on parse #4741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,6 +70,21 @@ def _parse_content_list(contents_data: Sequence[Any]) -> list[Content]: | |
| return contents | ||
|
|
||
|
|
||
| def normalize_function_call_arguments( | ||
| arguments: str | Mapping[str, Any] | None, | ||
| ) -> str | dict[str, Any] | None: | ||
| """Normalize provider tool-call arguments to a mapping when possible.""" | ||
| if not isinstance(arguments, str): | ||
| return dict(arguments) if isinstance(arguments, Mapping) else arguments | ||
|
|
||
| try: | ||
| loaded = json.loads(arguments) | ||
| except (json.JSONDecodeError, TypeError): | ||
| return arguments | ||
|
|
||
| return loaded if isinstance(loaded, dict) else arguments | ||
|
|
||
|
|
||
| # region Internal Helper functions for unified Content | ||
|
|
||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This utility function has five distinct code paths (None, non-str Mapping, non-str non-Mapping, valid JSON dict string, and non-dict-JSON/invalid-JSON string) but no direct unit tests. Add a parametrized test covering at least: |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,7 @@ | |
| Message, | ||
| ResponseStream, | ||
| UsageDetails, | ||
| normalize_function_call_arguments, | ||
| ) | ||
| from ..exceptions import ( | ||
| ChatClientException, | ||
|
|
@@ -556,7 +557,7 @@ def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Co | |
| fcc = Content.from_function_call( | ||
| call_id=tool.id if tool.id else "", | ||
| name=tool.function.name if tool.function.name else "", | ||
| arguments=tool.function.arguments if tool.function.arguments else "", | ||
| arguments=normalize_function_call_arguments(tool.function.arguments if tool.function.arguments else ""), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is called for both non-streaming |
||
| raw_representation=tool.function, | ||
| ) | ||
| resp.append(fcc) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -862,6 +862,32 @@ def test_function_approval_content_is_skipped_in_preparation( | |
| assert prepared_mixed[0]["content"] == "I need approval for this action." | ||
|
|
||
|
|
||
| def test_parse_tool_calls_from_openai_normalizes_json_object_arguments( | ||
| openai_unit_test_env: dict[str, str], | ||
| ) -> None: | ||
| client = OpenAIChatClient() | ||
|
|
||
| mock_tool_function = MagicMock() | ||
| mock_tool_function.name = "get_weather" | ||
| mock_tool_function.arguments = '{"city": "Seattle"}' | ||
|
|
||
| mock_tool = MagicMock() | ||
| mock_tool.id = "call_123" | ||
| mock_tool.function = mock_tool_function | ||
|
|
||
| mock_message = MagicMock() | ||
| mock_message.tool_calls = [mock_tool] | ||
|
|
||
| mock_choice = MagicMock() | ||
| mock_choice.delta = mock_message | ||
|
|
||
| contents = client._parse_tool_calls_from_openai(mock_choice) | ||
|
|
||
| assert len(contents) == 1 | ||
| assert contents[0].type == "function_call" | ||
| assert contents[0].arguments == {"city": "Seattle"} | ||
|
|
||
|
|
||
| def test_usage_content_in_streaming_response( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test only covers the JSON-object-string → dict path. Consider adding cases for non-JSON arguments (e.g., |
||
| openai_unit_test_env: dict[str, str], | ||
| ) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1037,7 +1037,7 @@ def test_response_content_creation_with_function_call() -> None: | |
| function_call = response.messages[0].contents[0] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only responses-client test covering function_call argument normalization in the non-streaming path. Consider adding a case where |
||
| assert function_call.call_id == "call_123" | ||
| assert function_call.name == "get_weather" | ||
| assert function_call.arguments == '{"location": "Seattle"}' | ||
| assert function_call.arguments == {"location": "Seattle"} | ||
|
|
||
|
|
||
| def test_prepare_content_for_opentool_approval_response() -> None: | ||
|
|
@@ -3581,7 +3581,7 @@ def test_parse_response_from_openai_function_call_includes_status() -> None: | |
| assert function_call.type == "function_call" | ||
| assert function_call.call_id == "call_123" | ||
| assert function_call.name == "get_weather" | ||
| assert function_call.arguments == '{"location": "Seattle"}' | ||
| assert function_call.arguments == {"location": "Seattle"} | ||
| # Verify status is included in additional_properties | ||
| assert function_call.additional_properties is not None | ||
| assert function_call.additional_properties.get("status") == "completed" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The normalization belongs inside
Content.from_function_call()rather than as a standalone helper. All current and future call sites automatically benefit, and the existingparse_arguments()method's JSON-parsing logic is no longer duplicated. Applying it only at selected call sites leads to aContentwhoseargumentstype depends on which parsing path produced it.