Skip to content

Commit dfc2dc2

Browse files
committed
refac
1 parent d784eb1 commit dfc2dc2

3 files changed

Lines changed: 81 additions & 7 deletions

File tree

backend/open_webui/env.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,13 @@ def parse_section(section):
630630
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
631631

632632

633+
# WARNING: Experimental. Only enable if your upstream Responses API endpoint
634+
# supports stateful sessions (i.e. server-side response storage with
635+
# previous_response_id anchoring). Most proxies and third-party endpoints
636+
# are stateless and will break if this is enabled.
637+
ENABLE_RESPONSES_API_STATEFUL = os.environ.get('ENABLE_RESPONSES_API_STATEFUL', 'False').lower() == 'true'
638+
639+
633640
CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get('CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE', '')
634641

635642
if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == '':

backend/open_webui/routers/openai.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,31 @@ def convert_to_azure_payload(url, payload: dict, api_version: str):
779779
return url, payload
780780

781781

782+
# Fields accepted by the Responses API for each input item type.
783+
RESPONSES_ALLOWED_FIELDS: dict[str, set[str]] = {
784+
'message': {'type', 'role', 'content'},
785+
'function_call': {'type', 'call_id', 'name', 'arguments', 'id'},
786+
'function_call_output': {'type', 'call_id', 'output'},
787+
}
788+
789+
790+
def _normalize_stored_item(item: dict) -> dict:
791+
"""Strip local-only fields from a stored output item before replaying it.
792+
793+
Open WebUI stores extra bookkeeping fields (``id``, ``status``,
794+
``started_at``, ``ended_at``, ``duration``, ``_tag_type``,
795+
``attributes``, ``summary``, etc.) that the Responses API does
796+
not accept. This helper returns a copy containing only the
797+
fields the API understands.
798+
"""
799+
item_type = item.get('type', '')
800+
allowed = RESPONSES_ALLOWED_FIELDS.get(item_type)
801+
if allowed is None:
802+
# Unknown type — pass through as-is (e.g. reasoning, extension items).
803+
return item
804+
return {k: v for k, v in item.items() if k in allowed}
805+
806+
782807
def convert_to_responses_payload(payload: dict) -> dict:
783808
"""
784809
Convert Chat Completions payload to Responses API format.
@@ -798,7 +823,7 @@ def convert_to_responses_payload(payload: dict) -> dict:
798823
# Check for stored output items (from previous Responses API turn)
799824
stored_output = msg.get('output')
800825
if stored_output and isinstance(stored_output, list):
801-
input_items.extend(stored_output)
826+
input_items.extend(_normalize_stored_item(item) for item in stored_output)
802827
continue
803828

804829
if role == 'system':
@@ -861,6 +886,12 @@ def convert_to_responses_payload(payload: dict) -> dict:
861886

862887
responses_payload = {**payload, 'input': input_items}
863888

889+
# Forward previous_response_id when the middleware has set it
890+
# (only used when ENABLE_RESPONSES_API_STATEFUL is enabled).
891+
previous_response_id = responses_payload.pop('previous_response_id', None)
892+
if previous_response_id:
893+
responses_payload['previous_response_id'] = previous_response_id
894+
864895
if system_content:
865896
responses_payload['instructions'] = system_content
866897

backend/open_webui/utils/middleware.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
ENABLE_FORWARD_USER_INFO_HEADERS,
137137
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
138138
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
139+
ENABLE_RESPONSES_API_STATEFUL,
139140
)
140141
from open_webui.utils.headers import include_user_info_headers
141142
from open_webui.constants import TASKS
@@ -850,7 +851,11 @@ def handle_responses_streaming_event(
850851
if item.get('type') == 'reasoning' and item.get('status') != 'completed':
851852
item['status'] = 'completed'
852853

853-
return new_output, {'usage': response_data.get('usage'), 'done': True}
854+
return new_output, {
855+
'usage': response_data.get('usage'),
856+
'done': True,
857+
'response_id': response_data.get('id'),
858+
}
854859

855860
elif event_type == 'response.in_progress':
856861
# State Machine Event: In Progress
@@ -3393,6 +3398,7 @@ def set_last_text(out, text):
33933398

33943399
usage = None
33953400
prior_output = []
3401+
last_response_id = None
33963402

33973403
def full_output():
33983404
return prior_output + output if prior_output else output
@@ -3431,6 +3437,7 @@ async def stream_body_handler(response, form_data):
34313437
nonlocal usage
34323438
nonlocal output
34333439
nonlocal prior_output
3440+
nonlocal last_response_id
34343441

34353442
response_tool_calls = []
34363443

@@ -3519,6 +3526,10 @@ async def flush_pending_delta_data(threshold: int = 0):
35193526
# calls. The outer middleware manages the
35203527
# actual completion signal.
35213528
if response_metadata:
3529+
if ENABLE_RESPONSES_API_STATEFUL:
3530+
response_id = response_metadata.pop('response_id', None)
3531+
if response_id:
3532+
last_response_id = response_id
35223533
processed_data.update(response_metadata)
35233534
processed_data.pop('done', None)
35243535

@@ -3927,10 +3938,12 @@ async def flush_pending_delta_data(threshold: int = 0):
39273938

39283939
# Responses API path: extract function_call items from output
39293940
if not response_tool_calls and output:
3930-
# Collect call_ids that already have results
3941+
# Collect call_ids that already have results,
3942+
# including those from prior_output so we don't
3943+
# re-process tool calls from a previous turn.
39313944
handled_call_ids = {
39323945
item.get('call_id')
3933-
for item in output
3946+
for item in (prior_output + output)
39343947
if item.get('type') == 'function_call_output'
39353948
}
39363949
responses_api_tool_calls = []
@@ -4249,11 +4262,20 @@ async def flush_pending_delta_data(threshold: int = 0):
42494262
**form_data,
42504263
'model': model_id,
42514264
'stream': True,
4252-
'messages': [
4265+
}
4266+
4267+
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
4268+
system_message = get_system_message(form_data['messages'])
4269+
new_form_data['messages'] = (
4270+
([system_message] if system_message else [])
4271+
+ convert_output_to_messages(output, raw=True)
4272+
)
4273+
new_form_data['previous_response_id'] = last_response_id
4274+
else:
4275+
new_form_data['messages'] = [
42534276
*form_data['messages'],
42544277
*convert_output_to_messages(output, raw=True),
4255-
],
4256-
}
4278+
]
42574279

42584280
res = await generate_chat_completion(
42594281
request,
@@ -4270,6 +4292,20 @@ async def flush_pending_delta_data(threshold: int = 0):
42704292
# ensures the UI shows tool history during
42714293
# streaming.
42724294
prior_output = list(output)
4295+
# Trim the trailing empty placeholder message
4296+
# so it doesn't persist as a ghost item once
4297+
# the new stream produces real content.
4298+
if (
4299+
prior_output
4300+
and prior_output[-1].get('type') == 'message'
4301+
and prior_output[-1].get('status') == 'in_progress'
4302+
):
4303+
msg_parts = prior_output[-1].get('content', [])
4304+
if (
4305+
not msg_parts
4306+
or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip())
4307+
):
4308+
prior_output.pop()
42734309
output = []
42744310
await stream_body_handler(res, new_form_data)
42754311
output[:0] = prior_output

0 commit comments

Comments
 (0)