Skip to content

Commit b302c2b

Browse files
wip: fix for none/null in function tool calls.
1 parent 10339b6 commit b302c2b

2 files changed

Lines changed: 197 additions & 5 deletions

File tree

instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,8 @@ def __init__(self, index, tool_call_id, function_name):
582582
self.arguments = []
583583

584584
def append_arguments(self, arguments):
585-
self.arguments.append(arguments)
585+
if arguments is not None:
586+
self.arguments.append(arguments)
586587

587588

588589
class ChoiceBuffer:
@@ -601,13 +602,18 @@ def append_tool_call(self, tool_call):
601602
for _ in range(len(self.tool_calls_buffers), idx + 1):
602603
self.tool_calls_buffers.append(None)
603604

605+
function = tool_call.function
604606
if not self.tool_calls_buffers[idx]:
605607
self.tool_calls_buffers[idx] = ToolCallBuffer(
606-
idx, tool_call.id, tool_call.function.name
608+
idx,
609+
tool_call.id,
610+
function.name if function else None,
611+
)
612+
613+
if function:
614+
self.tool_calls_buffers[idx].append_arguments(
615+
function.arguments
607616
)
608-
self.tool_calls_buffers[idx].append_arguments(
609-
tool_call.function.arguments
610-
)
611617

612618

613619
class BaseStreamWrapper:
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for ChoiceBuffer and ToolCallBuffer classes."""
16+
17+
from openai.types.chat.chat_completion_chunk import (
18+
ChoiceDeltaToolCall,
19+
ChoiceDeltaToolCallFunction,
20+
)
21+
22+
from opentelemetry.instrumentation.openai_v2.patch import (
23+
ChoiceBuffer,
24+
ToolCallBuffer,
25+
)
26+
27+
28+
class TestToolCallBuffer:
29+
def test_append_arguments_with_string(self):
30+
buf = ToolCallBuffer(0, "call_1", "get_weather")
31+
buf.append_arguments('{"city":')
32+
buf.append_arguments(' "NYC"}')
33+
assert "".join(buf.arguments) == '{"city": "NYC"}'
34+
35+
def test_append_arguments_with_none_is_skipped(self):
36+
"""Regression test for issue #4344.
37+
38+
Some OpenAI-compatible providers (vLLM, TGI, etc.) send
39+
arguments=None on tool-call delta chunks instead of arguments="".
40+
This must not crash when joining the arguments list.
41+
"""
42+
buf = ToolCallBuffer(0, "call_1", "get_weather")
43+
buf.append_arguments(None)
44+
buf.append_arguments('{"city": "NYC"}')
45+
buf.append_arguments(None)
46+
assert "".join(buf.arguments) == '{"city": "NYC"}'
47+
48+
def test_append_arguments_all_none(self):
49+
buf = ToolCallBuffer(0, "call_1", "get_weather")
50+
buf.append_arguments(None)
51+
buf.append_arguments(None)
52+
assert "".join(buf.arguments) == ""
53+
54+
def test_append_arguments_empty_string(self):
55+
buf = ToolCallBuffer(0, "call_1", "get_weather")
56+
buf.append_arguments("")
57+
buf.append_arguments('{"city": "NYC"}')
58+
assert "".join(buf.arguments) == '{"city": "NYC"}'
59+
60+
61+
class TestChoiceBuffer:
62+
def test_append_tool_call_with_none_arguments(self):
63+
"""End-to-end regression test for issue #4344.
64+
65+
Simulates the exact scenario from the bug report where a provider
66+
sends arguments=None on the first tool-call delta chunk.
67+
"""
68+
buf = ChoiceBuffer(0)
69+
buf.append_tool_call(
70+
ChoiceDeltaToolCall(
71+
index=0,
72+
id="call_1",
73+
type="function",
74+
function=ChoiceDeltaToolCallFunction(
75+
name="get_weather", arguments=None
76+
),
77+
)
78+
)
79+
buf.append_tool_call(
80+
ChoiceDeltaToolCall(
81+
index=0,
82+
function=ChoiceDeltaToolCallFunction(
83+
arguments='{"city": "NYC"}'
84+
),
85+
)
86+
)
87+
88+
# This must not raise TypeError
89+
result = "".join(buf.tool_calls_buffers[0].arguments)
90+
assert result == '{"city": "NYC"}'
91+
92+
def test_append_tool_call_normal_flow(self):
93+
"""Standard OpenAI flow where arguments="" on first delta."""
94+
buf = ChoiceBuffer(0)
95+
buf.append_tool_call(
96+
ChoiceDeltaToolCall(
97+
index=0,
98+
id="call_1",
99+
type="function",
100+
function=ChoiceDeltaToolCallFunction(
101+
name="get_weather", arguments=""
102+
),
103+
)
104+
)
105+
buf.append_tool_call(
106+
ChoiceDeltaToolCall(
107+
index=0,
108+
function=ChoiceDeltaToolCallFunction(
109+
arguments='{"city": "NYC"}'
110+
),
111+
)
112+
)
113+
114+
result = "".join(buf.tool_calls_buffers[0].arguments)
115+
assert result == '{"city": "NYC"}'
116+
117+
def test_append_multiple_tool_calls_with_none_arguments(self):
118+
"""Multiple tool calls where some have arguments=None."""
119+
buf = ChoiceBuffer(0)
120+
121+
# First tool call
122+
buf.append_tool_call(
123+
ChoiceDeltaToolCall(
124+
index=0,
125+
id="call_1",
126+
type="function",
127+
function=ChoiceDeltaToolCallFunction(
128+
name="get_weather", arguments=None
129+
),
130+
)
131+
)
132+
buf.append_tool_call(
133+
ChoiceDeltaToolCall(
134+
index=0,
135+
function=ChoiceDeltaToolCallFunction(
136+
arguments='{"city": "NYC"}'
137+
),
138+
)
139+
)
140+
141+
# Second tool call
142+
buf.append_tool_call(
143+
ChoiceDeltaToolCall(
144+
index=1,
145+
id="call_2",
146+
type="function",
147+
function=ChoiceDeltaToolCallFunction(
148+
name="get_time", arguments=None
149+
),
150+
)
151+
)
152+
buf.append_tool_call(
153+
ChoiceDeltaToolCall(
154+
index=1,
155+
function=ChoiceDeltaToolCallFunction(
156+
arguments='{"tz": "EST"}'
157+
),
158+
)
159+
)
160+
161+
assert "".join(buf.tool_calls_buffers[0].arguments) == '{"city": "NYC"}'
162+
assert "".join(buf.tool_calls_buffers[1].arguments) == '{"tz": "EST"}'
163+
164+
def test_append_tool_call_with_none_function(self):
165+
"""Handle delta chunks where function is None."""
166+
buf = ChoiceBuffer(0)
167+
buf.append_tool_call(
168+
ChoiceDeltaToolCall(
169+
index=0,
170+
id="call_1",
171+
type="function",
172+
function=ChoiceDeltaToolCallFunction(
173+
name="get_weather", arguments='{"city": "NYC"}'
174+
),
175+
)
176+
)
177+
# Subsequent delta with function=None should not crash
178+
buf.append_tool_call(
179+
ChoiceDeltaToolCall(
180+
index=0,
181+
function=None,
182+
)
183+
)
184+
185+
result = "".join(buf.tool_calls_buffers[0].arguments)
186+
assert result == '{"city": "NYC"}'

0 commit comments

Comments
 (0)