Skip to content

Commit 0223e8a

Browse files
tcx4c70dpage
authored andcommitted
fix(openai): tolerate empty/null fields in streaming tool-call deltas (pgadmin-org#9828)
Some OpenAI-compatible providers emit empty or null name/arguments/id fields in streaming continuation deltas to keep the response schema stable. pgAdmin's accumulator overwrote the real tool name (captured in the first delta) with the later null, producing a tool call named "null" that could not be dispatched. Skip falsy name/arguments/id when accumulating (matching the OpenAI Python SDK, which ignores nulls the same way) so the values captured in the first delta survive. Also guard against a null `function` object in a delta, which previously raised TypeError. Without the id guard a null id in a continuation delta clobbered the real id, which the final build then replaced with a random uuid rather than the provider's id. Adds a unit test covering the null-continuation, multi-chunk-arguments, and null-function cases, and a 9.16 release note.
1 parent 89e84eb commit 0223e8a

3 files changed

Lines changed: 129 additions & 4 deletions

File tree

docs/en_US/release_notes_9_16.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Bug fixes
3333
*********
3434

3535
| `Issue #9677 <https://github.com/pgadmin-org/pgadmin4/issues/9677>`_ - Fix the Unlogged table toggle in table properties not generating any ALTER TABLE ... SET LOGGED/UNLOGGED statement.
36+
| `Issue #9828 <https://github.com/pgadmin-org/pgadmin4/issues/9828>`_ - Fix tool calls failing against OpenAI-compatible providers that emit empty/null name, arguments, or id fields in streaming continuation deltas.
3637
| `Issue #9892 <https://github.com/pgadmin-org/pgadmin4/issues/9892>`_ - Fix blank difference counts on the top-level group rows in Schema Diff.
3738
| `Issue #9896 <https://github.com/pgadmin-org/pgadmin4/issues/9896>`_ - Fix invalid DDL reconstruction for SERIAL columns in Schema Diff and the generated SQL/CREATE Script so the output round-trips on a clean target.
3839
| `Issue #9935 <https://github.com/pgadmin-org/pgadmin4/issues/9935>`_ - Fix "Illegal instruction" crash on startup of the Linux DEB and RPM packages on older x86_64 CPUs by pinning the psycopg C extension build to the x86-64 baseline.

web/pgadmin/llm/providers/openai.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -780,12 +780,16 @@ def _read_openai_stream(
780780
'id': '', 'name': '', 'arguments': ''
781781
}
782782
tc = tool_calls_data[idx]
783-
if 'id' in tc_delta:
783+
# Some providers emit empty/null fields in continuation
784+
# deltas to keep the schema stable; skip them so a later
785+
# null doesn't clobber the real id/name captured earlier
786+
# (see the OpenAI SDK, which skips nulls the same way).
787+
if tc_delta.get('id'):
784788
tc['id'] = tc_delta['id']
785-
func = tc_delta.get('function', {})
786-
if 'name' in func:
789+
func = tc_delta.get('function') or {}
790+
if func.get('name'):
787791
tc['name'] = func['name']
788-
if 'arguments' in func:
792+
if func.get('arguments'):
789793
tc['arguments'] += func['arguments']
790794

791795
# Build final response
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
##########################################################################
2+
#
3+
# pgAdmin 4 - PostgreSQL Tools
4+
#
5+
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
6+
# This software is released under the PostgreSQL Licence
7+
#
8+
##########################################################################
9+
10+
"""Tests for OpenAI streaming tool-call accumulation."""
11+
12+
import json
13+
14+
from pgadmin.utils.route import BaseTestGenerator
15+
from pgadmin.llm.models import LLMResponse
16+
from pgadmin.llm.providers.openai import OpenAIClient
17+
18+
19+
class _FakeStream:
20+
"""Minimal response stand-in exposing readline() over SSE lines."""
21+
22+
def __init__(self, lines):
23+
# Each yielded line mimics the bytes a real socket returns,
24+
# including the trailing newline the parser strips.
25+
self._lines = [(line + '\n').encode('utf-8') for line in lines]
26+
self._idx = 0
27+
28+
def readline(self):
29+
if self._idx >= len(self._lines):
30+
return b''
31+
line = self._lines[self._idx]
32+
self._idx += 1
33+
return line
34+
35+
36+
def _sse(obj):
37+
return 'data: ' + json.dumps(obj)
38+
39+
40+
class OpenAIStreamToolCallTestCase(BaseTestGenerator):
41+
"""Accumulating tool-call deltas must tolerate empty/null fields."""
42+
43+
scenarios = [
44+
('Null name/id in continuation delta is ignored', dict(
45+
stream=[
46+
_sse({'choices': [{'index': 0, 'delta': {
47+
'role': 'assistant',
48+
'tool_calls': [{
49+
'index': 0, 'id': 'call_abc',
50+
'function': {'name': 'get_database_schema',
51+
'arguments': ''}}]}}]}),
52+
_sse({'choices': [{'index': 0, 'delta': {
53+
'tool_calls': [{
54+
'index': 0, 'id': None,
55+
'function': {'name': None,
56+
'arguments': '{}'}}]}}]}),
57+
_sse({'choices': [{'index': 0,
58+
'finish_reason': 'tool_calls',
59+
'delta': {}}]}),
60+
'data: [DONE]',
61+
],
62+
expected_name='get_database_schema',
63+
expected_arguments={},
64+
expected_id='call_abc',
65+
)),
66+
('Arguments streamed across chunks are concatenated', dict(
67+
stream=[
68+
_sse({'choices': [{'index': 0, 'delta': {
69+
'tool_calls': [{
70+
'index': 0, 'id': 'call_xyz',
71+
'function': {'name': 'run_query',
72+
'arguments': '{"sql":'}}]}}]}),
73+
_sse({'choices': [{'index': 0, 'delta': {
74+
'tool_calls': [{
75+
'index': 0,
76+
'function': {'arguments': '"SELECT 1"}'}}]}}]}),
77+
_sse({'choices': [{'index': 0,
78+
'finish_reason': 'tool_calls',
79+
'delta': {}}]}),
80+
'data: [DONE]',
81+
],
82+
expected_name='run_query',
83+
expected_arguments={'sql': 'SELECT 1'},
84+
expected_id='call_xyz',
85+
)),
86+
('Null function object in a delta does not raise', dict(
87+
stream=[
88+
_sse({'choices': [{'index': 0, 'delta': {
89+
'tool_calls': [{
90+
'index': 0, 'id': 'call_1',
91+
'function': {'name': 'noop',
92+
'arguments': '{}'}}]}}]}),
93+
_sse({'choices': [{'index': 0, 'delta': {
94+
'tool_calls': [{'index': 0, 'function': None}]}}]}),
95+
_sse({'choices': [{'index': 0,
96+
'finish_reason': 'tool_calls',
97+
'delta': {}}]}),
98+
'data: [DONE]',
99+
],
100+
expected_name='noop',
101+
expected_arguments={},
102+
expected_id='call_1',
103+
)),
104+
]
105+
106+
def runTest(self):
107+
client = OpenAIClient(api_key='test-key', model='gpt-4o')
108+
result = None
109+
for item in client._read_openai_stream(_FakeStream(self.stream)):
110+
if isinstance(item, LLMResponse):
111+
result = item
112+
113+
self.assertIsNotNone(result)
114+
self.assertEqual(len(result.tool_calls), 1)
115+
tc = result.tool_calls[0]
116+
self.assertEqual(tc.name, self.expected_name)
117+
self.assertEqual(tc.arguments, self.expected_arguments)
118+
# The real provider id must survive a null id in a later delta,
119+
# rather than being clobbered (and replaced by a random uuid).
120+
self.assertEqual(tc.id, self.expected_id)

0 commit comments

Comments
 (0)