From d5cacb3c0ae6addaf176b6648acd4693d5e91f94 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:18:26 +0200 Subject: [PATCH] fix: convert forced tool_choice and non-streaming tool calls for Responses API connections (#30095) On a connection with API type "responses", a forced tool choice sent in the Chat Completions shape was forwarded to the provider unchanged, so providers that validate the Responses API schema rejected the request. A non-streaming reply that carried a function call was also flattened to empty text with finish_reason "stop", so API clients never saw the tool call. The request converter now flattens a forced function choice to the Responses API shape, and the result converter turns every function_call output item into a Chat Completions tool_calls entry, mirroring the existing Responses output mapping in utils/misc.py. Fixes #30085 --- backend/open_webui/routers/openai.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 854a8f8389..63676d166e 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -1425,6 +1425,11 @@ def convert_to_responses_payload(payload: dict) -> dict: converted_tools.append(tool) responses_payload['tools'] = converted_tools + # Responses API expects a forced function choice as {"type": "function", "name": ...} + tool_choice = responses_payload.get('tool_choice') + if isinstance(tool_choice, dict) and isinstance(tool_choice.get('function'), dict): + responses_payload['tool_choice'] = {'type': 'function', 'name': tool_choice['function'].get('name', '')} + return responses_payload @@ -1432,17 +1437,32 @@ def convert_responses_result(response: dict) -> dict: """ Convert non-streaming Responses API result to Chat Completions format. - Extracts text from message output items so all downstream consumers + Extracts text and function calls from output items so all downstream consumers (frontend tasks, get_content_from_response) work without modification. """ output_items = response.get('output', []) content = '' + tool_calls = [] for item in output_items: if item.get('type') == 'message': for part in item.get('content', []): if part.get('type') == 'output_text': content += part.get('text', '') + elif item.get('type') == 'function_call': + arguments = item.get('arguments', '{}') + if not isinstance(arguments, str): + arguments = JSONCodec.dumps(arguments) + tool_calls.append( + { + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': { + 'name': item.get('name', ''), + 'arguments': arguments, + }, + } + ) return { 'id': response.get('id', ''), @@ -1454,8 +1474,9 @@ def convert_responses_result(response: dict) -> dict: 'message': { 'role': 'assistant', 'content': content, + **({'tool_calls': tool_calls} if tool_calls else {}), }, - 'finish_reason': 'stop', + 'finish_reason': 'tool_calls' if tool_calls else 'stop', } ], 'usage': response.get('usage', {}),