Skip to content

Commit 61fed95

Browse files
authored
OCI Provider: Fix pydantic validation errors during tool call with streaming. (#16899)
* logic to handle missing required fields in OCI streaming tool calls * Fix test mocks
1 parent adfdcf1 commit 61fed95

File tree

2 files changed

+308
-0
lines changed

2 files changed

+308
-0
lines changed

litellm/llms/oci/chat/transformation.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,17 @@ def _handle_cohere_stream_chunk(self, dict_chunk: dict):
13291329

13301330
def _handle_generic_stream_chunk(self, dict_chunk: dict):
13311331
"""Handle generic OCI streaming chunks."""
1332+
# Fix missing required fields in tool calls before Pydantic validation
1333+
# OCI streams tool calls progressively, so early chunks may be missing required fields
1334+
if dict_chunk.get("message") and dict_chunk["message"].get("toolCalls"):
1335+
for tool_call in dict_chunk["message"]["toolCalls"]:
1336+
if "arguments" not in tool_call:
1337+
tool_call["arguments"] = ""
1338+
if "id" not in tool_call:
1339+
tool_call["id"] = ""
1340+
if "name" not in tool_call:
1341+
tool_call["name"] = ""
1342+
13321343
try:
13331344
typed_chunk = OCIStreamChunk(**dict_chunk)
13341345
except TypeError as e:
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
"""
2+
Tests for OCI streaming responses with tool calls.
3+
4+
This test file specifically addresses the issue where OCI API returns tool calls
5+
without required fields like 'arguments', 'id', or 'name' during streaming,
6+
causing Pydantic validation errors.
7+
8+
Issue: OCI API returns tool calls with incomplete structures during streaming
9+
Error: ValidationError: 1 validation error for OCIStreamChunk message.toolCalls.0.arguments Field required
10+
"""
11+
import os
12+
import sys
13+
import pytest
14+
from unittest.mock import MagicMock
15+
16+
# Adds the parent directory to the system path
17+
sys.path.insert(0, os.path.abspath("../../../../.."))
18+
19+
from litellm.llms.oci.chat.transformation import OCIStreamWrapper
20+
from litellm.types.utils import ModelResponseStream
21+
22+
23+
class TestOCIStreamingToolCalls:
24+
"""Test cases for OCI streaming responses with incomplete tool call data."""
25+
26+
def test_stream_chunk_with_missing_arguments_field(self):
27+
"""
28+
Test that streaming chunks with tool calls missing 'arguments' field are handled.
29+
30+
OCI API can return tool calls in early chunks without the 'arguments' field,
31+
which should be filled with an empty string to satisfy Pydantic validation.
32+
"""
33+
# Mock streaming chunk with tool call missing 'arguments' field
34+
chunk_data = {
35+
"index": 0,
36+
"finishReason": None,
37+
"message": {
38+
"role": "ASSISTANT",
39+
"content": None,
40+
"toolCalls": [
41+
{
42+
"type": "FUNCTION",
43+
"id": "call_abc123",
44+
"name": "get_weather"
45+
# Note: 'arguments' field is missing
46+
}
47+
]
48+
}
49+
}
50+
51+
wrapper = OCIStreamWrapper(
52+
completion_stream=iter([]),
53+
model="meta.llama-3.1-405b-instruct",
54+
custom_llm_provider="oci",
55+
logging_obj=MagicMock()
56+
)
57+
58+
# This should not raise a ValidationError
59+
result = wrapper._handle_generic_stream_chunk(chunk_data)
60+
61+
assert isinstance(result, ModelResponseStream)
62+
assert len(result.choices) == 1
63+
assert result.choices[0].delta.tool_calls is not None
64+
assert len(result.choices[0].delta.tool_calls) == 1
65+
assert result.choices[0].delta.tool_calls[0]["function"]["arguments"] == ""
66+
67+
def test_stream_chunk_with_missing_id_field(self):
68+
"""
69+
Test that streaming chunks with tool calls missing 'id' field are handled.
70+
"""
71+
chunk_data = {
72+
"index": 0,
73+
"finishReason": None,
74+
"message": {
75+
"role": "ASSISTANT",
76+
"content": None,
77+
"toolCalls": [
78+
{
79+
"type": "FUNCTION",
80+
"name": "get_weather",
81+
"arguments": '{"location": "San Francisco"}'
82+
# Note: 'id' field is missing
83+
}
84+
]
85+
}
86+
}
87+
88+
wrapper = OCIStreamWrapper(
89+
completion_stream=iter([]),
90+
model="meta.llama-3.1-405b-instruct",
91+
custom_llm_provider="oci",
92+
logging_obj=MagicMock()
93+
)
94+
95+
result = wrapper._handle_generic_stream_chunk(chunk_data)
96+
97+
assert isinstance(result, ModelResponseStream)
98+
assert result.choices[0].delta.tool_calls is not None
99+
assert result.choices[0].delta.tool_calls[0]["id"] == ""
100+
101+
def test_stream_chunk_with_missing_name_field(self):
102+
"""
103+
Test that streaming chunks with tool calls missing 'name' field are handled.
104+
"""
105+
chunk_data = {
106+
"index": 0,
107+
"finishReason": None,
108+
"message": {
109+
"role": "ASSISTANT",
110+
"content": None,
111+
"toolCalls": [
112+
{
113+
"type": "FUNCTION",
114+
"id": "call_abc123",
115+
"arguments": '{"location": "San Francisco"}'
116+
# Note: 'name' field is missing
117+
}
118+
]
119+
}
120+
}
121+
122+
wrapper = OCIStreamWrapper(
123+
completion_stream=iter([]),
124+
model="meta.llama-3.1-405b-instruct",
125+
custom_llm_provider="oci",
126+
logging_obj=MagicMock()
127+
)
128+
129+
result = wrapper._handle_generic_stream_chunk(chunk_data)
130+
131+
assert isinstance(result, ModelResponseStream)
132+
assert result.choices[0].delta.tool_calls is not None
133+
assert result.choices[0].delta.tool_calls[0]["function"]["name"] == ""
134+
135+
def test_stream_chunk_with_all_missing_fields(self):
136+
"""
137+
Test that streaming chunks with tool calls missing all optional fields are handled.
138+
"""
139+
chunk_data = {
140+
"index": 0,
141+
"finishReason": None,
142+
"message": {
143+
"role": "ASSISTANT",
144+
"content": None,
145+
"toolCalls": [
146+
{
147+
"type": "FUNCTION"
148+
# All fields missing: id, name, arguments
149+
}
150+
]
151+
}
152+
}
153+
154+
wrapper = OCIStreamWrapper(
155+
completion_stream=iter([]),
156+
model="meta.llama-3.1-405b-instruct",
157+
custom_llm_provider="oci",
158+
logging_obj=MagicMock()
159+
)
160+
161+
result = wrapper._handle_generic_stream_chunk(chunk_data)
162+
163+
assert isinstance(result, ModelResponseStream)
164+
assert result.choices[0].delta.tool_calls is not None
165+
assert result.choices[0].delta.tool_calls[0]["id"] == ""
166+
assert result.choices[0].delta.tool_calls[0]["function"]["name"] == ""
167+
assert result.choices[0].delta.tool_calls[0]["function"]["arguments"] == ""
168+
169+
def test_stream_chunk_with_complete_tool_call(self):
170+
"""
171+
Test that streaming chunks with complete tool calls still work correctly.
172+
"""
173+
chunk_data = {
174+
"index": 0,
175+
"finishReason": None,
176+
"message": {
177+
"role": "ASSISTANT",
178+
"content": None,
179+
"toolCalls": [
180+
{
181+
"type": "FUNCTION",
182+
"id": "call_abc123",
183+
"name": "get_weather",
184+
"arguments": '{"location": "San Francisco", "unit": "celsius"}'
185+
}
186+
]
187+
}
188+
}
189+
190+
wrapper = OCIStreamWrapper(
191+
completion_stream=iter([]),
192+
model="meta.llama-3.1-405b-instruct",
193+
custom_llm_provider="oci",
194+
logging_obj=MagicMock()
195+
)
196+
197+
result = wrapper._handle_generic_stream_chunk(chunk_data)
198+
199+
assert isinstance(result, ModelResponseStream)
200+
assert result.choices[0].delta.tool_calls is not None
201+
assert len(result.choices[0].delta.tool_calls) == 1
202+
assert result.choices[0].delta.tool_calls[0]["id"] == "call_abc123"
203+
assert result.choices[0].delta.tool_calls[0]["function"]["name"] == "get_weather"
204+
assert result.choices[0].delta.tool_calls[0]["function"]["arguments"] == '{"location": "San Francisco", "unit": "celsius"}'
205+
206+
def test_stream_chunk_with_multiple_tool_calls_missing_fields(self):
207+
"""
208+
Test that streaming chunks with multiple tool calls, some with missing fields, are handled.
209+
"""
210+
chunk_data = {
211+
"index": 0,
212+
"finishReason": None,
213+
"message": {
214+
"role": "ASSISTANT",
215+
"content": None,
216+
"toolCalls": [
217+
{
218+
"type": "FUNCTION",
219+
"id": "call_1",
220+
"name": "get_weather"
221+
# Missing arguments
222+
},
223+
{
224+
"type": "FUNCTION",
225+
"name": "get_time",
226+
"arguments": '{"timezone": "UTC"}'
227+
# Missing id
228+
},
229+
{
230+
"type": "FUNCTION",
231+
"id": "call_3",
232+
"name": "calculate",
233+
"arguments": '{"expression": "2+2"}'
234+
# Complete
235+
}
236+
]
237+
}
238+
}
239+
240+
wrapper = OCIStreamWrapper(
241+
completion_stream=iter([]),
242+
model="meta.llama-3.1-405b-instruct",
243+
custom_llm_provider="oci",
244+
logging_obj=MagicMock()
245+
)
246+
247+
result = wrapper._handle_generic_stream_chunk(chunk_data)
248+
249+
assert isinstance(result, ModelResponseStream)
250+
assert result.choices[0].delta.tool_calls is not None
251+
assert len(result.choices[0].delta.tool_calls) == 3
252+
253+
# First tool call - missing arguments
254+
assert result.choices[0].delta.tool_calls[0]["id"] == "call_1"
255+
assert result.choices[0].delta.tool_calls[0]["function"]["name"] == "get_weather"
256+
assert result.choices[0].delta.tool_calls[0]["function"]["arguments"] == ""
257+
258+
# Second tool call - missing id
259+
assert result.choices[0].delta.tool_calls[1]["id"] == ""
260+
assert result.choices[0].delta.tool_calls[1]["function"]["name"] == "get_time"
261+
assert result.choices[0].delta.tool_calls[1]["function"]["arguments"] == '{"timezone": "UTC"}'
262+
263+
# Third tool call - complete
264+
assert result.choices[0].delta.tool_calls[2]["id"] == "call_3"
265+
assert result.choices[0].delta.tool_calls[2]["function"]["name"] == "calculate"
266+
assert result.choices[0].delta.tool_calls[2]["function"]["arguments"] == '{"expression": "2+2"}'
267+
268+
def test_stream_chunk_without_tool_calls(self):
269+
"""
270+
Test that streaming chunks without tool calls continue to work as before.
271+
"""
272+
chunk_data = {
273+
"index": 0,
274+
"finishReason": None,
275+
"message": {
276+
"role": "ASSISTANT",
277+
"content": [
278+
{
279+
"type": "TEXT",
280+
"text": "Hello, how can I help you?"
281+
}
282+
]
283+
}
284+
}
285+
286+
wrapper = OCIStreamWrapper(
287+
completion_stream=iter([]),
288+
model="meta.llama-3.1-405b-instruct",
289+
custom_llm_provider="oci",
290+
logging_obj=MagicMock()
291+
)
292+
293+
result = wrapper._handle_generic_stream_chunk(chunk_data)
294+
295+
assert isinstance(result, ModelResponseStream)
296+
assert result.choices[0].delta.content == "Hello, how can I help you?"
297+
assert result.choices[0].delta.tool_calls is None

0 commit comments

Comments
 (0)