Skip to content

Commit 822cd77

Browse files
fix(genai-openai): fix for ChoiceBuffer crashes on streaming tool-call deltas with arguments=None. (open-telemetry#4350)
* wip: fix for none/null in function tool calls. * polish: add changelog. * wip: fixing lint and precommit. * wip: converted class based tests. * wip: fixing the precommit.
1 parent 70f2b8c commit 822cd77

File tree

3 files changed

+193
-5
lines changed

3 files changed

+193
-5
lines changed

instrumentation-genai/opentelemetry-instrumentation-openai-v2/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
- Fix `ChoiceBuffer` crash on streaming tool-call deltas with `arguments=None`
11+
([#4350](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4350))
1012
- Fix `StreamWrapper` missing `.headers` and other attributes when using `with_raw_response` streaming
1113
([#4113](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4113))
1214
- Add opt-in support for latest experimental semantic conventions (v1.37.0). Set

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

Lines changed: 9 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,16 @@ 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,
607611
)
608-
self.tool_calls_buffers[idx].append_arguments(
609-
tool_call.function.arguments
610-
)
612+
613+
if function:
614+
self.tool_calls_buffers[idx].append_arguments(function.arguments)
611615

612616

613617
class BaseStreamWrapper:
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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+
def test_toolcallbuffer_append_arguments_with_string():
29+
buf = ToolCallBuffer(0, "call_1", "get_weather")
30+
buf.append_arguments('{"city":')
31+
buf.append_arguments(' "NYC"}')
32+
assert "".join(buf.arguments) == '{"city": "NYC"}'
33+
34+
35+
def test_toolcallbuffer_append_arguments_with_none_is_skipped():
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+
49+
def test_toolcallbuffer_append_arguments_all_none():
50+
buf = ToolCallBuffer(0, "call_1", "get_weather")
51+
buf.append_arguments(None)
52+
buf.append_arguments(None)
53+
assert "".join(buf.arguments) == ""
54+
55+
56+
def test_toolcallbuffer_append_arguments_empty_string():
57+
buf = ToolCallBuffer(0, "call_1", "get_weather")
58+
buf.append_arguments("")
59+
buf.append_arguments('{"city": "NYC"}')
60+
assert "".join(buf.arguments) == '{"city": "NYC"}'
61+
62+
63+
def test_choicebuffer_append_tool_call_with_none_arguments():
64+
"""End-to-end regression test for issue #4344.
65+
66+
Simulates the exact scenario from the bug report where a provider
67+
sends arguments=None on the first tool-call delta chunk.
68+
"""
69+
buf = ChoiceBuffer(0)
70+
buf.append_tool_call(
71+
ChoiceDeltaToolCall(
72+
index=0,
73+
id="call_1",
74+
type="function",
75+
function=ChoiceDeltaToolCallFunction(
76+
name="get_weather", arguments=None
77+
),
78+
)
79+
)
80+
buf.append_tool_call(
81+
ChoiceDeltaToolCall(
82+
index=0,
83+
function=ChoiceDeltaToolCallFunction(arguments='{"city": "NYC"}'),
84+
)
85+
)
86+
87+
# This must not raise TypeError
88+
result = "".join(buf.tool_calls_buffers[0].arguments)
89+
assert result == '{"city": "NYC"}'
90+
91+
92+
def test_choicebuffer_append_tool_call_normal_flow():
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(arguments='{"city": "NYC"}'),
109+
)
110+
)
111+
112+
result = "".join(buf.tool_calls_buffers[0].arguments)
113+
assert result == '{"city": "NYC"}'
114+
115+
116+
def test_choicebuffer_append_multiple_tool_calls_with_none_arguments():
117+
"""Multiple tool calls where some have arguments=None."""
118+
buf = ChoiceBuffer(0)
119+
120+
# First tool call
121+
buf.append_tool_call(
122+
ChoiceDeltaToolCall(
123+
index=0,
124+
id="call_1",
125+
type="function",
126+
function=ChoiceDeltaToolCallFunction(
127+
name="get_weather", arguments=None
128+
),
129+
)
130+
)
131+
buf.append_tool_call(
132+
ChoiceDeltaToolCall(
133+
index=0,
134+
function=ChoiceDeltaToolCallFunction(arguments='{"city": "NYC"}'),
135+
)
136+
)
137+
138+
# Second tool call
139+
buf.append_tool_call(
140+
ChoiceDeltaToolCall(
141+
index=1,
142+
id="call_2",
143+
type="function",
144+
function=ChoiceDeltaToolCallFunction(
145+
name="get_time", arguments=None
146+
),
147+
)
148+
)
149+
buf.append_tool_call(
150+
ChoiceDeltaToolCall(
151+
index=1,
152+
function=ChoiceDeltaToolCallFunction(arguments='{"tz": "EST"}'),
153+
)
154+
)
155+
156+
assert "".join(buf.tool_calls_buffers[0].arguments) == '{"city": "NYC"}'
157+
assert "".join(buf.tool_calls_buffers[1].arguments) == '{"tz": "EST"}'
158+
159+
160+
def test_choicebuffer_append_tool_call_with_none_function():
161+
"""Handle delta chunks where function is None."""
162+
buf = ChoiceBuffer(0)
163+
buf.append_tool_call(
164+
ChoiceDeltaToolCall(
165+
index=0,
166+
id="call_1",
167+
type="function",
168+
function=ChoiceDeltaToolCallFunction(
169+
name="get_weather", arguments='{"city": "NYC"}'
170+
),
171+
)
172+
)
173+
# Subsequent delta with function=None should not crash
174+
buf.append_tool_call(
175+
ChoiceDeltaToolCall(
176+
index=0,
177+
function=None,
178+
)
179+
)
180+
181+
result = "".join(buf.tool_calls_buffers[0].arguments)
182+
assert result == '{"city": "NYC"}'

0 commit comments

Comments
 (0)