From 7cf0ed80215edf5ccd6815947da895f9a45e4c1f Mon Sep 17 00:00:00 2001 From: DrMelone <27028174+Classic298@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:05:26 +0200 Subject: [PATCH] feat: render Responses API built-in tool output items Add rendering support for OpenAI Responses API built-in server-side tool output items: web_search_call, file_search_call, and computer_call. These are emitted when the model uses OpenAI's native tools (web_search, file_search, computer_use) through the Responses API. The streaming handler already accumulated these items correctly, but serialize_output silently dropped them during rendering. Now they render as collapsible tool call blocks matching the existing UI pattern. Also extract url_citation annotations from Responses API output items (produced by the built-in web search tool) and emit them as source events so the frontend displays proper citation links. - Deduplicate citations by URL to prevent duplicate source events - HTML-escape all dynamic IDs interpolated into HTML attributes - Defensive type handling for file_search_call queries and results --- backend/open_webui/utils/middleware.py | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 54df422c4b..d170175614 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -523,6 +523,60 @@ def serialize_output(output: list) -> str: else: content += f'
\nAnalyzing…\n{display}\n
\n' + elif item_type == 'web_search_call': + # Responses API built-in web search tool output + if content and not content.endswith('\n'): + content += '\n' + + search_id = html.escape(str(item.get('id') or '')) + search_status = item.get('status', 'completed') + is_done = search_status != 'in_progress' + + if is_done: + summary = 'Search failed' if search_status == 'failed' else 'Searched the web' + content += f'
\n{summary}\n
\n' + else: + content += f'
\nSearching the web…\n
\n' + + elif item_type == 'file_search_call': + # Responses API built-in file search tool output + if content and not content.endswith('\n'): + content += '\n' + + search_id = html.escape(str(item.get('id') or '')) + search_status = item.get('status', 'completed') + is_done = search_status != 'in_progress' + queries = item.get('queries') or [] + queries_json = json.dumps(queries if isinstance(queries, list) else [str(queries)], ensure_ascii=False) + + if is_done: + results = item.get('results') or [] + result_summary = json.dumps( + [r.get('filename', str(r.get('text') or '')[:80]) for r in (results[:5] if isinstance(results, list) else []) if isinstance(r, dict)], + ensure_ascii=False, + ) if results else '[]' + summary = 'Search failed' if search_status == 'failed' else 'Searched files' + content += f'
\n{summary}\n
\n' + else: + content += f'
\nSearching files…\n
\n' + + elif item_type == 'computer_call': + # Responses API built-in computer use tool output + if content and not content.endswith('\n'): + content += '\n' + + call_id = html.escape(str(item.get('call_id') or item.get('id') or '')) + call_status = item.get('status', 'completed') + is_done = call_status != 'in_progress' + action = item.get('action', {}) + action_json = json.dumps(action, ensure_ascii=False) if isinstance(action, dict) else json.dumps(str(action)) + + if is_done: + summary = 'Action failed' if call_status == 'failed' else 'Used computer' + content += f'
\n{summary}\n
\n' + else: + content += f'
\nUsing computer…\n
\n' + return content.strip() @@ -3481,6 +3535,7 @@ async def streaming_chat_response_handler(response, ctx): usage = None prior_output = [] last_response_id = None + emitted_citation_urls = set() def full_output(): return prior_output + output if prior_output else output @@ -3620,6 +3675,47 @@ async def streaming_chat_response_handler(response, ctx): 'data': processed_data, } ) + + # Extract URL citations from Responses API output items + # (e.g. from OpenAI's built-in web_search tool). + # Only emit on response.completed to avoid duplicates + # (response.output_item.done fires per-item, then + # response.completed includes all items again). + if data.get('type') == 'response.completed': + for out_item in data.get('response', {}).get('output', []): + if not isinstance(out_item, dict): + continue + for part in (out_item.get('content') or []): + if not isinstance(part, dict): + continue + for annotation in (part.get('annotations') or []): + if not isinstance(annotation, dict): + continue + if annotation.get('type') == 'url_citation': + cite_url = annotation.get('url', '') + if not isinstance(cite_url, str) or not cite_url or cite_url in emitted_citation_urls: + continue + emitted_citation_urls.add(cite_url) + cite_title = annotation.get('title', cite_url) + await event_emitter( + { + 'type': 'source', + 'data': { + 'source': { + 'name': cite_title, + 'url': cite_url, + }, + 'document': [cite_title], + 'metadata': [ + { + 'source': cite_url, + 'name': cite_title, + } + ], + }, + } + ) + continue else: choices = data.get('choices', [])