From 496cabf0dfd08576efcbf23f8a97dc8aea11dacc Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Sat, 27 Jun 2026 20:10:55 +0000 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- .../async_context_compression.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 8e49a92..1a503e9 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -6018,6 +6018,26 @@ async def outlet( else "outlet-body" ) + # When the body lacks per-message ids (the common case for plain chat, + # where OpenWebUI does not put message node ids into the request body), + # _save_summary cannot build branch refs and fails closed. Fall back to + # the DB active branch, which carries ids, when the unfolded body shape + # matches the persisted branch. This mirrors the idless reuse path in + # _load_applicable_summary_snapshot so plain-chat summaries persist. + if self._current_branch_refs(summary_messages) is None and chat_id: + db_messages_fallback = await self._load_full_chat_messages(chat_id) + ( + compatible_db_messages, + _db_to_body_boundaries, + _ignored_terminal_assistant, + ) = self._compatible_db_branch_for_body_ref_fallback( + summary_messages, + db_messages_fallback, + ) + if compatible_db_messages is not None: + summary_messages = compatible_db_messages + message_source = f"{message_source}+db-refs" + restored_count_before = len(summary_messages) summary_messages = self._restore_pending_inlet_messages( chat_id, summary_messages @@ -6758,6 +6778,12 @@ async def _generate_summary_async( log_type="warning", event_call=__event_call__, ) + await self._emit_summary_terminal_status( + __event_emitter__, + lang, + "summary generated but was not persisted", + ) + return source_refs = self._current_branch_refs(messages) or [] source_current_id = source_refs[-1]["id"] if source_refs else None From 98362b60b796424b6ae09c69a136a2d808b1f8cb Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Sat, 27 Jun 2026 20:25:29 +0000 Subject: [PATCH 02/16] diag: add temporary log to diagnose plain-chat summary persist failure --- .../async_context_compression.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 1a503e9..beba7b2 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -6771,6 +6771,24 @@ async def _generate_summary_async( messages, saved_compressed_count, ) + # [TEMP DIAGNOSIS] figure out why _save_summary fails for plain chat + _diag_msgs_with_id = sum( + 1 + for m in messages + if isinstance(m, dict) and (m.get("id") or m.get("message_id")) + ) + _diag_marker_count = sum( + 1 for m in messages if isinstance(m, dict) and self._is_summary_message(m) + ) + await self._log( + "[๐Ÿค– Async Summary Task] ๐Ÿ” [DIAG] refs diagnosis: " + f"saved_compressed_count={saved_compressed_count} | " + f"covered_refs={'None' if covered_refs is None else f'list[len={len(covered_refs)}]'} | " + f"messages_len={len(messages)} | messages_with_id={_diag_msgs_with_id} | " + f"summary_marker_count={_diag_marker_count} | " + f"summary_db_available={getattr(self, '_summary_db_available', 'N/A')}", + event_call=__event_call__, + ) if covered_refs is None: await self._log( "[๐Ÿค– Async Summary Task] โš ๏ธ Covered range lacks stable message refs; " From ad5d9d6fe7edda76b58237f3be77722ee171afde Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Sat, 27 Jun 2026 20:42:14 +0000 Subject: [PATCH 03/16] diag: surface _init_database failure detail on event channel --- .../async_context_compression.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index beba7b2..240c12e 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -3268,7 +3268,22 @@ def _init_database(self): self._summary_db_available = True except Exception as e: - logger.error(f"[Database] โŒ Initialization failed: {str(e)}") + logger.error( + f"[Database] โŒ Initialization failed: {str(e)}\n" + f"[Database] โŒ Exception type: {type(e).__name__}\n" + f"[Database] โŒ _db_engine={self._db_engine}\n" + f"[Database] โŒ owui_db={getattr(self, '_owui_db', None)}\n" + f"[Database] โŒ Traceback:", + exc_info=True, + ) + # [TEMP DIAGNOSIS] surface the init failure on the OpenWebUI event channel + try: + import traceback as _tb + self._summary_db_init_error = ( + f"{type(e).__name__}: {e}\n" + "".join(_tb.format_exc()) + ) + except Exception: + self._summary_db_init_error = str(e) class Valves(BaseModel): priority: int = Field( @@ -6786,7 +6801,8 @@ async def _generate_summary_async( f"covered_refs={'None' if covered_refs is None else f'list[len={len(covered_refs)}]'} | " f"messages_len={len(messages)} | messages_with_id={_diag_msgs_with_id} | " f"summary_marker_count={_diag_marker_count} | " - f"summary_db_available={getattr(self, '_summary_db_available', 'N/A')}", + f"summary_db_available={getattr(self, '_summary_db_available', 'N/A')} | " + f"summary_db_init_error={getattr(self, '_summary_db_init_error', 'none')}", event_call=__event_call__, ) if covered_refs is None: From 524bb27e83a9dc50ae50b1a62fc32bee60d8be2d Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Sat, 27 Jun 2026 20:45:50 +0000 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- .../async_context_compression.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 240c12e..6515dda 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -389,6 +389,7 @@ MetaData, Table, inspect, + text as sqlalchemy_text, ) from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy.engine import Engine @@ -3076,6 +3077,53 @@ def _drop_table_if_exists(self, table_name: str): ) table.drop(bind=self._db_engine, checkfirst=True) + def _drop_legacy_chat_summary_indexes(self): + """Drop indexes that share names SQLAlchemy will reuse for the new table. + + PostgreSQL keeps an index alive even after its parent table is dropped + when the index name collides with one the new schema recreates (e.g. the + legacy unique index ``ix_chat_summary_chat_id``), which makes the + subsequent CREATE INDEX fail with ``DuplicateTable``. Drop any such + leftover indexes before recreating the table. + """ + # Index names the new ChatSummary table will create (Column index=True + # โ†’ ix__, plus the explicit unique dedup index). + names_to_drop = { + "ix_chat_summary_chat_id", + "ix_chat_summary_covered_refs_hash", + "ix_chat_summary_branch_tip_id", + CHAT_SUMMARY_DEDUP_INDEX_NAME, + } + try: + inspector = inspect(self._db_engine) + existing = set() + for idx in inspector.get_indexes( + "chat_summary", schema=owui_schema + ): + name = idx.get("name") + if name: + existing.add(name) + except Exception as exc: + logger.warning( + f"[Database] โš ๏ธ Could not list chat_summary indexes before rebuild: {exc}" + ) + return + + to_drop = names_to_drop & existing + if not to_drop: + return + + with self._db_engine.begin() as connection: + for name in sorted(to_drop): + # Identifier quoting via SQLAlchemy text + bindparam is awkward + # for DDL; names are internal constants so plain format is safe. + connection.execute( + sqlalchemy_text(f'DROP INDEX IF EXISTS "{name}"') + ) + logger.info( + f"[Database] Dropped legacy chat_summary indexes before rebuild: {sorted(to_drop)}" + ) + def _deduplicate_chat_summary_rows(self) -> int: target_table = ChatSummary.__table__ deleted_count = 0 @@ -3239,6 +3287,12 @@ def _init_database(self): if schema_is_branch_aware is None: return if not schema_is_branch_aware: + # PostgreSQL may leave behind indexes that share the name + # SQLAlchemy will reuse for the new table (e.g. the legacy + # unique index ix_chat_summary_chat_id). Drop them first so + # CREATE TABLE / CREATE INDEX does not collide with + # "relation ... already exists" (DuplicateTable). + self._drop_legacy_chat_summary_indexes() ChatSummary.__table__.drop(bind=self._db_engine, checkfirst=True) has_summary_table = False logger.info( From a84f7bb9a85d4b01094b9db2ac8074291a8bebe7 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Sat, 27 Jun 2026 20:47:43 +0000 Subject: [PATCH 05/16] fix(db): clear legacy chat_summary indexes before rebuild to avoid DuplicateTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: when an existing chat_summary table is legacy (missing branch-aware columns), _init_database drops it and recreates via SQLAlchemy. On PostgreSQL a leftover index sharing the name SQLAlchemy reuses (e.g. the old unique ix_chat_summary_chat_id) survives and makes CREATE INDEX fail with DuplicateTable, which aborts init and leaves _summary_db_available=False โ€” so every subsequent _save_summary is rejected at the first guard and summaries never persist. Fix: before dropping the legacy table, run DROP INDEX IF EXISTS for all index names the new schema will create. Idempotent and safe on both PostgreSQL and SQLite. Also enrich the init-failure log with exception type and full traceback for future diagnosis. --- .../async_context_compression.py | 93 ++++++------------- 1 file changed, 30 insertions(+), 63 deletions(-) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 6515dda..cf272ad 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -389,7 +389,6 @@ MetaData, Table, inspect, - text as sqlalchemy_text, ) from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy.engine import Engine @@ -3080,49 +3079,46 @@ def _drop_table_if_exists(self, table_name: str): def _drop_legacy_chat_summary_indexes(self): """Drop indexes that share names SQLAlchemy will reuse for the new table. - PostgreSQL keeps an index alive even after its parent table is dropped - when the index name collides with one the new schema recreates (e.g. the - legacy unique index ``ix_chat_summary_chat_id``), which makes the - subsequent CREATE INDEX fail with ``DuplicateTable``. Drop any such - leftover indexes before recreating the table. + PostgreSQL may keep an index alive even after its parent table is + dropped (e.g. when a previous, partially-failed init left a legacy + unique index ``ix_chat_summary_chat_id`` behind), which makes the + subsequent CREATE INDEX fail with ``DuplicateTable``. Best-effort, + idempotent cleanup of any candidate colliding names before recreating + the table. ``DROP INDEX IF EXISTS`` is a no-op when the index is + already gone, so this is safe on both PostgreSQL and SQLite. """ # Index names the new ChatSummary table will create (Column index=True - # โ†’ ix_
_, plus the explicit unique dedup index). - names_to_drop = { + # โ†’ ix_
_, plus the explicit unique dedup index). Also + # cover the legacy unique index name old versions used on chat_id. + names_to_drop = [ "ix_chat_summary_chat_id", "ix_chat_summary_covered_refs_hash", "ix_chat_summary_branch_tip_id", CHAT_SUMMARY_DEDUP_INDEX_NAME, - } - try: - inspector = inspect(self._db_engine) - existing = set() - for idx in inspector.get_indexes( - "chat_summary", schema=owui_schema - ): - name = idx.get("name") - if name: - existing.add(name) - except Exception as exc: - logger.warning( - f"[Database] โš ๏ธ Could not list chat_summary indexes before rebuild: {exc}" - ) - return + ] - to_drop = names_to_drop & existing - if not to_drop: - return + from sqlalchemy import text as sqlalchemy_text + dropped: list[str] = [] with self._db_engine.begin() as connection: - for name in sorted(to_drop): - # Identifier quoting via SQLAlchemy text + bindparam is awkward - # for DDL; names are internal constants so plain format is safe. - connection.execute( - sqlalchemy_text(f'DROP INDEX IF EXISTS "{name}"') + for name in names_to_drop: + qualified = ( + f'{owui_schema}."{name}"' if owui_schema else f'"{name}"' ) - logger.info( - f"[Database] Dropped legacy chat_summary indexes before rebuild: {sorted(to_drop)}" - ) + try: + connection.execute( + sqlalchemy_text(f"DROP INDEX IF EXISTS {qualified}") + ) + dropped.append(name) + except Exception as exc: + # Non-fatal: we only want to clear the way for CREATE INDEX. + logger.warning( + f"[Database] โš ๏ธ Could not drop legacy index {name}: {exc}" + ) + if dropped: + logger.info( + f"[Database] Cleared legacy chat_summary index names before rebuild: {dropped}" + ) def _deduplicate_chat_summary_rows(self) -> int: target_table = ChatSummary.__table__ @@ -3325,19 +3321,9 @@ def _init_database(self): logger.error( f"[Database] โŒ Initialization failed: {str(e)}\n" f"[Database] โŒ Exception type: {type(e).__name__}\n" - f"[Database] โŒ _db_engine={self._db_engine}\n" - f"[Database] โŒ owui_db={getattr(self, '_owui_db', None)}\n" f"[Database] โŒ Traceback:", exc_info=True, ) - # [TEMP DIAGNOSIS] surface the init failure on the OpenWebUI event channel - try: - import traceback as _tb - self._summary_db_init_error = ( - f"{type(e).__name__}: {e}\n" + "".join(_tb.format_exc()) - ) - except Exception: - self._summary_db_init_error = str(e) class Valves(BaseModel): priority: int = Field( @@ -6840,25 +6826,6 @@ async def _generate_summary_async( messages, saved_compressed_count, ) - # [TEMP DIAGNOSIS] figure out why _save_summary fails for plain chat - _diag_msgs_with_id = sum( - 1 - for m in messages - if isinstance(m, dict) and (m.get("id") or m.get("message_id")) - ) - _diag_marker_count = sum( - 1 for m in messages if isinstance(m, dict) and self._is_summary_message(m) - ) - await self._log( - "[๐Ÿค– Async Summary Task] ๐Ÿ” [DIAG] refs diagnosis: " - f"saved_compressed_count={saved_compressed_count} | " - f"covered_refs={'None' if covered_refs is None else f'list[len={len(covered_refs)}]'} | " - f"messages_len={len(messages)} | messages_with_id={_diag_msgs_with_id} | " - f"summary_marker_count={_diag_marker_count} | " - f"summary_db_available={getattr(self, '_summary_db_available', 'N/A')} | " - f"summary_db_init_error={getattr(self, '_summary_db_init_error', 'none')}", - event_call=__event_call__, - ) if covered_refs is None: await self._log( "[๐Ÿค– Async Summary Task] โš ๏ธ Covered range lacks stable message refs; " From ef434e859ad89f4e527a2dd4052fe36bef4d4180 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Sat, 27 Jun 2026 21:38:09 +0000 Subject: [PATCH 06/16] fix(db): dedup stale ChatSummary index definitions in shared metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (confirmed by user: no orphan index in pg_indexes, but init still fails with DuplicateTable on ix_chat_summary_chat_id): OpenWebUI reuses a single declarative base (owui_Base) across plugin reloads. When the old ChatSummary class (unique index on chat_id) is replaced by the new one (plain index=True), extend_existing=True merges columns but keeps BOTH index definitions alive in metadata โ€” both named ix_chat_summary_chat_id but with different uniqueness. CREATE TABLE then emits two CREATE INDEX statements that collide by name, aborting the whole transaction (table is not created, _summary_db_available stays False). The DROP INDEX IF EXISTS approach from the previous commit does not help because the collision is in-process metadata, not in the database. Fix: before CREATE TABLE, scan ChatSummary.__table__.indexes (and the shared metadata table) for same-name duplicates and discard the stale copies, preferring the non-unique variant to match the current schema. This runs only when the table needs to be created. --- .../async_context_compression.py | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index cf272ad..bf1e1fc 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -3120,6 +3120,86 @@ def _drop_legacy_chat_summary_indexes(self): f"[Database] Cleared legacy chat_summary index names before rebuild: {dropped}" ) + def _dedup_chat_summary_metadata_indexes(self): + """Remove colliding index definitions from the shared declarative metadata. + + OpenWebUI reuses a single declarative base across plugin reloads. When + an older ChatSummary definition (with a unique index on ``chat_id``) + is replaced by a newer one (plain ``index=True`` on ``chat_id``), the + ``extend_existing=True`` table arg merges columns but leaves BOTH index + definitions alive in ``owui_Base.metadata`` โ€” and they share the name + ``ix_chat_summary_chat_id``. CREATE TABLE then emits two CREATE INDEX + statements with the same name, the second one failing with + ``DuplicateTable``. + + For each index name present more than once on the chat_summary table, + keep the one declared by the current class (in + ``ChatSummary.__table__.indexes`` set by class body evaluation) and + discard the duplicates. If all copies claim to be "current" (same + object identity is impossible since they differ), prefer the non-unique + one to match the new schema intent. + """ + table = ChatSummary.__table__ + # Group every index on the table by name. + by_name: dict[str, list[Any]] = {} + for idx in list(table.indexes): + name = getattr(idx, "name", None) + if name: + by_name.setdefault(name, []).append(idx) + + # Also scan the shared metadata table in case extend_existing attached + # stale indexes that are not in ChatSummary.__table__.indexes. + metadata_table = owui_Base.metadata.tables.get("chat_summary") + if metadata_table is not None and metadata_table is not table: + for idx in list(metadata_table.indexes): + name = getattr(idx, "name", None) + if name: + bucket = by_name.setdefault(name, []) + if idx not in bucket: + bucket.append(idx) + + stale_indexes: list[tuple[str, Any]] = [] + for name, bucket in by_name.items(): + if len(bucket) <= 1: + continue + # Prefer the non-unique copy: the current schema declares + # chat_id/covered_refs_hash/branch_tip_id as plain index=True and + # only the explicit dedup index is unique. A stale unique copy on + # a name that should now be non-unique is the legacy leftover. + non_unique = [i for i in bucket if not getattr(i, "unique", False)] + unique = [i for i in bucket if getattr(i, "unique", False)] + # When we have both unique and non-unique copies of the same name, + # keep one non-unique (current) and drop the rest. + if non_unique and unique: + keep = non_unique[0] + drop = [i for i in bucket if i is not keep] + else: + # All copies share uniqueness flag โ€” drop all but the first. + keep = bucket[0] + drop = [i for i in bucket if i is not keep] + for idx in drop: + stale_indexes.append((name, idx)) + + if not stale_indexes: + return + + for name, idx in stale_indexes: + try: + table.indexes.discard(idx) + except Exception as exc: + logger.warning( + f"[Database] โš ๏ธ Could not detach stale metadata index {name}: {exc}" + ) + if metadata_table is not None: + try: + metadata_table.indexes.discard(idx) + except Exception: + pass + logger.info( + f"[Database] Detached stale chat_summary metadata indexes: " + f"{[name for name, _ in stale_indexes]}" + ) + def _deduplicate_chat_summary_rows(self) -> int: target_table = ChatSummary.__table__ deleted_count = 0 @@ -3296,6 +3376,11 @@ def _init_database(self): ) if not has_summary_table: + # Clear stale index definitions from the shared declarative + # metadata before CREATE TABLE โ€” otherwise SQLAlchemy may emit + # two CREATE INDEX statements that share a name (legacy unique + # vs. new non-unique) and abort the whole CREATE TABLE. + self._dedup_chat_summary_metadata_indexes() # Create the chat_summary table if it doesn't exist ChatSummary.__table__.create(bind=self._db_engine, checkfirst=True) logger.info( From 9f15b1cbfe003f1b04816f44ac2d6096b44b2fae Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 10:18:40 +0000 Subject: [PATCH 07/16] fix(inlet): add position-based fallback for reasoning models (issue #98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: for reasoning models, OpenWebUI regenerates assistant content from the output array via convert_output_to_messages before the inlet filter runs, so body content (reasoning stripped) structurally differs from DB content (folded reasoning in
). The existing folded and unfolded comparison paths in _body_to_db_coverage_map_for_ref_fallback both fail on this content mismatch, so the snapshot is rejected on every inlet and the full uncompressed history is sent โ€” even though the outlet already validated the snapshot against DB refs. Fix: add Path 3, a position-based fallback that accepts the DB refs by position when: - the body carries no OpenWebUI message ids (plain chat) - count and role sequence match the DB active branch 1:1 - _unfold_db_branch_for_body_ref_fallback produced non-empty output (rules out conversion failures) - _body_position_matches_db_message passes, which: * always requires tool_calls / tool_call_id to match (catches tampered tool calls) * requires exact content match for DB messages WITHOUT an output array (catches user-edited bodies) * skips content comparison for DB messages WITH an output array (content was rebuilt by process_messages_with_output) This keeps the strict content checks for ordinary chats (no output array) while allowing reasoning-model chats (output array โ†’ rebuilt content) to reuse the saved summary on the inlet. --- .../async_context_compression.py | 114 ++++++++++++++++-- .../test_async_context_compression.py | 111 +++++++++++++++++ 2 files changed, 213 insertions(+), 12 deletions(-) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index bf1e1fc..74daf8e 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -1099,6 +1099,64 @@ def _get_message_id(self, message: Dict[str, Any]) -> Optional[str]: message_id = message.get("id") or message.get("message_id") return message_id if isinstance(message_id, str) and message_id else None + def _normalize_role(self, role: Any) -> str: + """Normalize chat-completion roles for position-based comparison. + + OpenWebUI persists tool outputs under role 'tool', but some rebuild + paths may surface them as 'function' (legacy OpenAI shape). Collapse + both to 'tool' so a bodyโ†”DB role sequence stays comparable even when + the exact role string differs across rebuild paths. + """ + if not isinstance(role, str): + return "" + if role == "function": + return "tool" + return role + + def _body_position_matches_db_message( + self, + body_message: Dict[str, Any], + db_message: Dict[str, Any], + ) -> bool: + """Position-based match used when content-level comparison is unsafe. + + OpenWebUI's ``process_messages_with_output`` regenerates assistant + ``content`` from the ``output`` array before the inlet filter runs, + so for DB messages that carry an ``output`` array the body content + structurally differs from the persisted content (e.g. reasoning is + folded into a ``
`` block in the DB but + stripped from the body). Content comparison cannot succeed there. + + This helper validates the parts that ``process_messages_with_output`` + preserves verbatim: + - role (normalized) + - tool_calls (rebuilt from output, but the function names/args match) + - tool_call_id + + For DB messages WITHOUT an ``output`` array the content is not + rebuilt, so it must still match exactly โ€” this catches genuine edits + (user-edited body payloads, corrupted tool calls) that position+role + alignment alone would miss. + """ + if self._normalize_role(body_message.get("role")) != self._normalize_role( + db_message.get("role") + ): + return False + + if body_message.get("tool_calls") != db_message.get("tool_calls"): + return False + + if body_message.get("tool_call_id") != db_message.get("tool_call_id"): + return False + + db_output = db_message.get("output") + has_db_output = isinstance(db_output, list) and bool(db_output) + if not has_db_output: + if body_message.get("content") != db_message.get("content"): + return False + + return True + def _message_fingerprint(self, message: Dict[str, Any]) -> str: """Fingerprint the model-visible payload to detect in-place edits.""" payload = self._message_fingerprint_payload(message) @@ -2430,22 +2488,54 @@ def _body_to_db_coverage_map_for_ref_fallback( unfolded_messages, db_to_body_boundaries = ( self._unfold_db_branch_for_body_ref_fallback(db_messages) ) - if len(body_messages) != len(unfolded_messages): - return None - - if not all( - self._body_message_matches_unfolded_db_message( - body_message, - unfolded_message, + if ( + len(body_messages) == len(unfolded_messages) + and all( + self._body_message_matches_unfolded_db_message( + body_message, + unfolded_message, + ) + for body_message, unfolded_message in zip( + body_messages, + unfolded_messages, + ) ) - for body_message, unfolded_message in zip( - body_messages, - unfolded_messages, + ): + return db_to_body_boundaries + + # Path 3 (position-based fallback): when the body carries no + # OpenWebUI message ids and matches the DB active branch 1:1 in + # count and role sequence, accept the DB refs by position. + # + # This covers reasoning models (and any future rebuild path) where + # OpenWebUI regenerates assistant ``content`` from the ``output`` + # array via ``convert_output_to_messages`` before the inlet filter + # runs, so the body content structurally differs from the persisted + # DB content (e.g. reasoning is folded into a + # ``
`` block in the DB but stripped from + # the body). Content-level comparison cannot succeed in that case. + # + # Guards against false positives: + # - ``unfolded_messages`` non-empty rules out conversion failures + # (when ``_unfold_db_branch_for_body_ref_fallback`` cannot parse + # the output array it returns ``[]``; we keep rejecting in that + # case so a corrupt output never silently passes). + # - ``_body_position_matches_db_message`` still requires exact + # ``content`` match for DB messages WITHOUT ``output`` (catches + # user-edited bodies) and always requires ``tool_calls`` / + # ``tool_call_id`` to match (catches tampered tool calls). + if ( + unfolded_messages + and all(not self._get_message_id(m) for m in body_messages) + and len(body_messages) == len(db_messages) + and all( + self._body_position_matches_db_message(body_message, db_message) + for body_message, db_message in zip(body_messages, db_messages) ) ): - return None + return list(range(len(db_messages) + 1)) - return db_to_body_boundaries + return None def _compatible_db_branch_for_body_ref_fallback( self, diff --git a/plugins/filters/async-context-compression/test_async_context_compression.py b/plugins/filters/async-context-compression/test_async_context_compression.py index 4ea8adb..db3e3e4 100644 --- a/plugins/filters/async-context-compression/test_async_context_compression.py +++ b/plugins/filters/async-context-compression/test_async_context_compression.py @@ -1983,6 +1983,117 @@ async def noop(*args, **kwargs): ) ) + def test_inlet_applies_summary_for_reasoning_model_via_position_fallback(self): + """Issue #98: reasoning models rebuild assistant content from output, + so body content (no reasoning) โ‰  DB content (folded reasoning). The + position-based fallback must accept the snapshot so the summary is + actually injected on the inlet.""" + self.filter.valves.keep_last = 0 + db_messages = [ + {"id": "m0", "role": "user", "content": "message m0"}, + { + "id": "m1", + "role": "assistant", + "content": '
hidden reasoning chain
\nvisible answer', + "output": [ + {"type": "reasoning", "summary": [{"type": "output_text", "text": "hidden reasoning chain"}]}, + {"type": "message", "content": [{"type": "output_text", "text": "visible answer"}]}, + ], + }, + {"id": "m2", "role": "user", "content": "message m2"}, + ] + # Body content is what process_messages_with_output produces: + # reasoning stripped (reasoning_format=None), only "visible answer". + body_messages = [ + {"role": "user", "content": "message m0"}, + {"role": "assistant", "content": "visible answer"}, + {"role": "user", "content": "message m2"}, + ] + snapshots = [ + _snapshot( + "reasoning model summary", + self.filter._message_refs_for_prefix(db_messages, 2), + ) + ] + + async def fake_load_snapshots(chat_id): + return snapshots + + async def fake_load_live_refs(chat_id): + return _live_refs_by_id(self.filter, db_messages) + + async def fake_load_full_chat_messages(chat_id): + return db_messages + + async def noop(*args, **kwargs): + return None + + self.filter._load_summary_snapshots = fake_load_snapshots + self.filter._load_chat_history_live_refs = fake_load_live_refs + self.filter._load_full_chat_messages = fake_load_full_chat_messages + self.filter._log = noop + self.filter._emit_debug_log = noop + self.filter._get_model_thresholds = lambda model_id: { + "max_context_tokens": 0 + } + + result = asyncio.run( + self.filter.inlet( + { + "chat_id": "chat-1", + "model": "test-model", + "messages": body_messages, + } + ) + ) + final_messages = result["messages"] + + self.assertTrue(self.filter._is_summary_message(final_messages[0])) + self.assertIn("reasoning model summary", final_messages[0]["content"]) + self.assertEqual(final_messages[1]["content"], "message m2") + + def test_position_fallback_rejects_edited_content_when_db_has_no_output(self): + """Position fallback must still reject when DB has no output array + and the body content was edited (not rebuilt by OWUI).""" + self.filter.valves.keep_last = 0 + db_messages = _messages_with_ids([f"m{i}" for i in range(3)]) + body_messages = [ + {"role": "user", "content": "message m0"}, + {"role": "assistant", "content": "EDITED, not the original"}, + {"role": "user", "content": "message m2"}, + ] + + result = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, + db_messages, + ) + self.assertIsNone(result) + + def test_position_fallback_accepts_reasoning_content_mismatch(self): + """Position fallback accepts content differences ONLY for DB messages + that carry an output array (i.e. content was rebuilt by OWUI).""" + db_messages = [ + {"id": "m0", "role": "user", "content": "message m0"}, + { + "id": "m1", + "role": "assistant", + "content": "
reasoning
\nanswer", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "answer"}]}], + }, + {"id": "m2", "role": "user", "content": "message m2"}, + ] + body_messages = [ + {"role": "user", "content": "message m0"}, + {"role": "assistant", "content": "answer"}, # rebuilt, reasoning stripped + {"role": "user", "content": "message m2"}, + ] + + result = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, + db_messages, + ) + self.assertEqual(result, [0, 1, 2, 3]) + def test_unfold_db_branch_fallback_rejects_conversion_errors(self): misc_module = _ensure_module("open_webui.utils.misc") From 1416abb0d1c9d37c7f0b2ca9fcba589384576d38 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 10:45:07 +0000 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- _verify_owui/middleware.py | 5272 +++++++++++++++++ _verify_owui/misc.py | 1095 ++++ .../async-context-compression/_diag.py | 60 + .../test_issue98_e2e.py | 782 +++ 4 files changed, 7209 insertions(+) create mode 100644 _verify_owui/middleware.py create mode 100644 _verify_owui/misc.py create mode 100644 plugins/filters/async-context-compression/_diag.py create mode 100644 plugins/filters/async-context-compression/test_issue98_e2e.py diff --git a/_verify_owui/middleware.py b/_verify_owui/middleware.py new file mode 100644 index 0000000..63de31f --- /dev/null +++ b/_verify_owui/middleware.py @@ -0,0 +1,5272 @@ +import ast +import asyncio +import base64 +import copy +import html +import inspect +import json +import logging +import os +import random +import re +import sys +import textwrap +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Optional +from uuid import uuid4 + +from aiocache import cached +from fastapi import HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse +from open_webui.config import ( + CACHE_DIR, + CODE_INTERPRETER_BLOCKED_MODULES, + CODE_INTERPRETER_PYODIDE_PROMPT, + DEFAULT_CODE_INTERPRETER_PROMPT, + DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, +) +from open_webui.constants import TASKS +from open_webui.env import ( + BYPASS_MODEL_ACCESS_CONTROL, + CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS, + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, + ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION, + ENABLE_QUERIES_CACHE, + ENABLE_REALTIME_CHAT_SAVE, + ENABLE_RESPONSES_API_STATEFUL, + GLOBAL_LOG_LEVEL, + RAG_SYSTEM_CONTEXT, +) +from open_webui.models.chats import Chats +from open_webui.models.folders import Folders +from open_webui.models.functions import Functions +from open_webui.models.models import Models +from open_webui.models.oauth_sessions import OAuthSessions +from open_webui.models.users import UserModel, Users +from open_webui.retrieval.utils import get_sources_from_items +from open_webui.routers.images import ( + CreateImageForm, + EditImageForm, + image_edits, + image_generations, +) +from open_webui.routers.memories import QueryMemoryForm, query_memory +from open_webui.routers.pipelines import ( + process_pipeline_inlet_filter, + process_pipeline_outlet_filter, +) +from open_webui.routers.retrieval import ( + SearchForm, + process_web_search, +) +from open_webui.routers.tasks import ( + generate_chat_tags, + generate_follow_ups, + generate_image_prompt, + generate_queries, + generate_title, +) +from open_webui.socket.main import ( + get_event_call, + get_event_emitter, +) +from open_webui.utils.access_control import has_connection_access, has_permission +from open_webui.utils.access_control.files import get_accessible_folder_files +from open_webui.utils.chat import generate_chat_completion +from open_webui.utils.code_interpreter import execute_code_jupyter +from open_webui.utils.files import ( + convert_markdown_base64_images, + get_file_url_from_base64, + get_image_base64_from_url, + get_image_url_from_base64, +) +from open_webui.utils.filter import ( + get_sorted_filter_ids, + process_filter_functions, +) + +from open_webui.utils.mcp.client import MCPClient +from open_webui.utils.misc import ( + add_or_update_system_message, + add_or_update_user_message, + convert_logit_bias_input_to_json, + convert_output_to_messages, + deep_update, + extract_urls, + get_content_from_message, + get_last_assistant_message, + get_last_user_message, + get_last_user_message_item, + get_message_list, + get_system_message, + is_string_allowed, + merge_system_messages, + prepend_to_first_user_message_content, + replace_system_message_content, + set_last_user_message_content, + strip_empty_content_blocks, +) +from open_webui.utils.payload import apply_system_prompt_to_body +from open_webui.utils.plugin import load_function_module_by_id +from open_webui.utils.response import normalize_usage +from open_webui.utils.sanitize import sanitize_code +from open_webui.utils.task import ( + get_task_model_id, + rag_template, + tools_function_calling_generation_template, +) +from open_webui.utils.tools import ( + build_tool_server_headers, + get_builtin_tools, + get_terminal_tools, + get_tools, + get_updated_tool_function, +) +from open_webui.utils.webhook import post_webhook +from starlette.responses import JSONResponse, Response, StreamingResponse + +logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) +log = logging.getLogger(__name__) + + +# We believe in one maker of all models, seen and unseen, +# and in the reasoning which proceeds from the architect. +# We look for the resurrection of dead processes and the +# inference of the world to come. +DEFAULT_REASONING_TAGS = [ + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('<|begin_of_thought|>', '<|end_of_thought|>'), + ('โ—thinkโ–ท', 'โ—/thinkโ–ท'), +] +DEFAULT_SOLUTION_TAGS = [('<|begin_of_solution|>', '<|end_of_solution|>')] +DEFAULT_CODE_INTERPRETER_TAGS = [('', '')] + + +def output_id(prefix: str) -> str: + """Generate OR-style ID: prefix + 24-char hex UUID.""" + return f'{prefix}_{uuid4().hex[:24]}' + + +def _split_tool_calls( + tool_calls: list[dict], +) -> list[dict]: + """Expand tool calls whose arguments contain multiple back-to-back JSON objects. + + Some models (e.g. GPT-5.4) send multiple complete JSON argument objects + under the same tool call index, producing concatenated invalid JSON like: + '{"query":"A","count":5}{"query":"B","count":5}' + + Each such tool call is split into separate entries so each gets executed + independently. Single-object arguments pass through unchanged. + """ + + def split_json_objects(raw: str) -> list[str]: + decoder = json.JSONDecoder() + results = [] + position = 0 + + while position < len(raw): + while position < len(raw) and raw[position].isspace(): + position += 1 + if position >= len(raw): + break + try: + _, end = decoder.raw_decode(raw, position) + results.append(raw[position:end].strip()) + position = end + except json.JSONDecodeError: + return [raw] + + return results or [raw] + + expanded = [] + for tool_call in tool_calls: + arguments = tool_call.get('function', {}).get('arguments', '') + split_arguments = split_json_objects(arguments) + + if len(split_arguments) <= 1: + expanded.append(tool_call) + else: + for argument in split_arguments: + cloned = copy.deepcopy(tool_call) + cloned['id'] = f'call_{uuid4().hex[:24]}' + cloned['function']['arguments'] = argument + expanded.append(cloned) + + return expanded + + +def get_citation_source_from_tool_result( + tool_name: str, tool_params: dict, tool_result: str, tool_id: str = '' +) -> list[dict]: + """ + Parse a tool's result and convert it to source dicts for citation display. + + Follows the source format conventions from get_sources_from_items: + - source: file/item info object with id, name, type + - document: list of document contents + - metadata: list of metadata objects with source, file_id, name fields + + Returns a list of sources (usually one, but query_knowledge_files may return multiple). + """ + _EXPECTS_LIST = {'search_web', 'query_knowledge_files'} + _EXPECTS_DICT = {'view_knowledge_file', 'view_file'} + + try: + try: + tool_result = json.loads(tool_result) + except (json.JSONDecodeError, TypeError): + pass # keep tool_result as-is (e.g. fetch_url returns plain text) + if isinstance(tool_result, dict) and 'error' in tool_result: + return [] + + # Validate tool_result type based on what the branch expects + if tool_name in _EXPECTS_LIST and not isinstance(tool_result, list): + return [] + elif tool_name in _EXPECTS_DICT and not isinstance(tool_result, dict): + return [] + + if tool_name == 'search_web': + # Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}] + results = tool_result + documents = [] + metadata = [] + + for result in results: + title = result.get('title', '') + link = result.get('link', '') + snippet = result.get('snippet', '') + + documents.append(f'{title}\n{snippet}') + metadata.append( + { + 'source': link, + 'name': title, + 'url': link, + } + ) + + return [ + { + 'source': {'name': 'search_web', 'id': 'search_web'}, + 'document': documents, + 'metadata': metadata, + } + ] + + elif tool_name in ('view_knowledge_file', 'view_file'): + file_data = tool_result + filename = file_data.get('filename', 'Unknown File') + file_id = file_data.get('id', '') + knowledge_name = file_data.get('knowledge_name', '') + + return [ + { + 'source': { + 'id': file_id, + 'name': filename, + 'type': 'file', + }, + 'document': [file_data.get('content', '')], + 'metadata': [ + { + 'file_id': file_id, + 'name': filename, + 'source': filename, + **({'knowledge_name': knowledge_name} if knowledge_name else {}), + } + ], + } + ] + + elif tool_name == 'fetch_url': + url = tool_params.get('url', '') + content = tool_result if isinstance(tool_result, str) else str(tool_result) + snippet = content[:500] + ('...' if len(content) > 500 else '') + + return [ + { + 'source': {'name': url or 'fetch_url', 'id': url or 'fetch_url'}, + 'document': [snippet], + 'metadata': [ + { + 'source': url, + 'name': url, + 'url': url, + } + ], + } + ] + + elif tool_name == 'query_knowledge_files': + chunks = tool_result + + # Group chunks by source for better citation display + # Each unique source becomes a separate source entry + sources_by_file = {} + + for chunk in chunks: + source_name = chunk.get('source', 'Unknown') + file_id = chunk.get('file_id', '') + note_id = chunk.get('note_id', '') + chunk_type = chunk.get('type', 'file') + content = chunk.get('content', '') + + # Use file_id or note_id as the key + key = file_id or note_id or source_name + + if key not in sources_by_file: + sources_by_file[key] = { + 'source': { + 'id': file_id or note_id, + 'name': source_name, + 'type': chunk_type, + }, + 'document': [], + 'metadata': [], + } + + sources_by_file[key]['document'].append(content) + sources_by_file[key]['metadata'].append( + { + 'file_id': file_id, + 'name': source_name, + 'source': source_name, + **({'note_id': note_id} if note_id else {}), + } + ) + + # Return all grouped sources as a list + if sources_by_file: + return list(sources_by_file.values()) + + # Empty result fallback + return [] + + else: + # Fallback for other tools + return [ + { + 'source': { + 'name': tool_name, + 'type': 'tool', + 'id': tool_id or tool_name, + }, + 'document': [str(tool_result)], + 'metadata': [{'source': tool_name, 'name': tool_name}], + } + ] + except Exception as e: + log.exception(f'Error parsing tool result for {tool_name}: {e}') + return [ + { + 'source': {'name': tool_name, 'type': 'tool'}, + 'document': [str(tool_result)], + 'metadata': [{'source': tool_name}], + } + ] + + +def split_content_and_whitespace(content): + content_stripped = content.rstrip() + original_whitespace = content[len(content_stripped) :] if len(content) > len(content_stripped) else '' + return content_stripped, original_whitespace + + +def is_opening_code_block(content): + backtick_segments = content.split('```') + # Even number of segments means the last backticks are opening a new block + return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 + + +_OPENAI_TOOL_DISPLAY_NAMES = { + 'web_search_call': 'Web Search', + 'file_search_call': 'File Search', + 'computer_call': 'Computer Use', +} + + +def _render_openai_tool_call_handler(item: dict, done: bool) -> str: + """Render an OpenAI Responses API server-side tool item as a
block. + + Handles web_search_call, file_search_call, and computer_call items whose + schemas are defined in the openai-python SDK (generated from OpenAPI spec). + """ + item_type = item.get('type', '') + call_id = item.get('id', '') + display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type) + + # Build a short summary of what the tool did + summary = '' + if item_type == 'web_search_call': + action = item.get('action', {}) + if isinstance(action, dict): + atype = action.get('type', '') + if atype == 'search': + queries = action.get('queries') or [] + query = action.get('query', '') + summary = ( + f'Search: {", ".join(str(q) for q in queries)}' + if queries + else (f'Search: {query}' if query else '') + ) + elif atype == 'open_page': + summary = f'Open page: {action.get("url", "")}' if action.get('url') else '' + elif atype == 'find_in_page': + summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else '' + elif item_type == 'file_search_call': + queries = item.get('queries', []) + if queries: + summary = f'Queries: {", ".join(str(q) for q in queries)}' + elif item_type == 'computer_call': + action = item.get('action') + actions = item.get('actions') + if isinstance(action, dict): + summary = f'Action: {action.get("type", "unknown")}' + elif isinstance(actions, list) and actions: + summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}' + + escaped_name = html.escape(display_name) + if done: + return f'
\nTool Executed\n{html.escape(summary)}\n
\n' + return f'
\nExecuting...\n
\n' + + +def serialize_output(output: list) -> str: + """ + Convert OR-aligned output items to HTML for display. + For LLM consumption, use convert_output_to_messages() instead. + """ + parts: list[str] = [] + + # First pass: collect function_call_output items by call_id for lookup + tool_outputs = {} + for item in output: + if item.get('type') == 'function_call_output': + tool_outputs[item.get('call_id')] = item + + # Second pass: render items in order + for idx, item in enumerate(output): + item_type = item.get('type', '') + + if item_type == 'message': + for content_part in item.get('content', []): + if 'text' in content_part: + text = content_part.get('text', '').strip() + if text: + parts.append(text) + + elif item_type == 'function_call': + call_id = item.get('call_id', '') + name = item.get('name', '') + arguments = item.get('arguments', '') + + result_item = tool_outputs.get(call_id) + if result_item: + result_parts: list[str] = [] + for result_output in result_item.get('output', []): + if 'text' in result_output: + output_text = result_output.get('text', '') + result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text) + result_text = ''.join(result_parts) + files = result_item.get('files') + embeds = result_item.get('embeds', '') + + parts.append( + f'
\nTool Executed\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n
' + ) + else: + parts.append( + f'
\nExecuting...\n
' + ) + + elif item_type == 'function_call_output': + # Already handled inline with function_call above + pass + + elif item_type in _OPENAI_TOOL_DISPLAY_NAMES: + status = item.get('status', 'in_progress') + done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1 + parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n')) + + elif item_type == 'reasoning': + reasoning_parts: list[str] = [] + # Check for 'summary' (new structure) or 'content' (legacy/fallback) + source_list = item.get('summary', []) or item.get('content', []) + for content_part in source_list: + if 'text' in content_part: + reasoning_parts.append(content_part.get('text', '')) + elif 'summary' in content_part: # Handle potential nested logic if any + pass + + reasoning_content = ''.join(reasoning_parts).strip() + + duration = item.get('duration') + status = item.get('status', 'in_progress') + + # Infer completion: if this reasoning item is NOT the last item, + # render as done (a subsequent item means reasoning is complete) + is_last_item = idx == len(output) - 1 + + display = html.escape( + '\n'.join( + (f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines() + ) + ) + + if status == 'completed' or duration is not None or not is_last_item: + parts.append( + f'
\nThought for {duration or 0} seconds\n{display}\n
' + ) + else: + parts.append( + f'
\nThinkingโ€ฆ\n{display}\n
' + ) + + elif item_type == 'open_webui:code_interpreter': + # Code interpreter needs to inspect/mutate prior accumulated content + # to strip trailing unclosed code fences โ€” materialize only here. + content = '\n'.join(parts) + content_stripped, original_whitespace = split_content_and_whitespace(content) + if is_opening_code_block(content_stripped): + content = content_stripped.rstrip('`').rstrip() + original_whitespace + else: + content = content_stripped + original_whitespace + + # Re-split back into parts list after mutation + parts = [content] if content else [] + + # Render the code_interpreter item as a
block + # so the frontend Collapsible renders "Analyzing..."/"Analyzed". + code = item.get('code', '').strip() + lang = item.get('lang', 'python') + status = item.get('status', 'in_progress') + duration = item.get('duration') + is_last_item = idx == len(output) - 1 + + # Build inner content: code block + display = '' + if code: + display = f'```{lang}\n{code}\n```' + + # Build output attribute as HTML-escaped JSON for CodeBlock.svelte + ci_output = item.get('output') + output_attr = '' + if ci_output: + if isinstance(ci_output, dict): + output_json = json.dumps(ci_output, ensure_ascii=False) + else: + output_json = json.dumps({'result': str(ci_output)}, ensure_ascii=False) + output_attr = f' output="{html.escape(output_json)}"' + + if status == 'completed' or duration is not None or not is_last_item: + parts.append( + f'
\nAnalyzed\n{display}\n
' + ) + else: + parts.append( + f'
\nAnalyzingโ€ฆ\n{display}\n
' + ) + + return '\n'.join(parts).strip() + + +def deep_merge(target, source): + """ + Merge source into target recursively (returning new structure). + - Dicts: Recursive merge. + - Strings: Concatenation. + - Others: Overwrite. + """ + if isinstance(target, dict) and isinstance(source, dict): + new_target = target.copy() + for k, v in source.items(): + if k in new_target: + new_target[k] = deep_merge(new_target[k], v) + else: + new_target[k] = v + return new_target + elif isinstance(target, str) and isinstance(source, str): + return target + source + else: + return source + + +def handle_responses_streaming_event( + data: dict, + current_output: list, +) -> tuple[list, dict | None]: + """ + Handle Responses API streaming events in a pure functional way. + + Args: + data: The event data + current_output: List of output items (treated as immutable) + + Returns: + tuple[list, dict | None]: (new_output, metadata) + - new_output: The updated output list. + - metadata: Metadata to emit (e.g. usage), {} if update occurred, None if skip. + """ + # Default: no change + # Note: treating current_output as immutable, but avoiding full deepcopy for perf. + # We will shallow copy only if we need to modify the list structure or items. + + event_type = data.get('type', '') + + if event_type == 'response.output_item.added': + item = data.get('item', {}) + if item: + new_output = list(current_output) + new_output.append(item) + return new_output, None + return current_output, None + + elif event_type == 'response.content_part.added': + part = data.get('part', {}) + output_index = data.get('output_index', len(current_output) - 1) + + if current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + # Copy the item to mutate it + item = new_output[output_index].copy() + new_output[output_index] = item + + if 'content' not in item: + item['content'] = [] + else: + # Copy content list + item['content'] = list(item['content']) + + if item.get('type') == 'reasoning': + # Reasoning items should not have content parts + pass + else: + item['content'].append(part) + return new_output, None + return current_output, None + + elif event_type == 'response.reasoning_summary_part.added': + part = data.get('part', {}) + output_index = data.get('output_index', len(current_output) - 1) + + if current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + + if 'summary' not in item: + item['summary'] = [] + else: + item['summary'] = list(item['summary']) + + item['summary'].append(part) + return new_output, None + return current_output, None + + elif event_type.startswith('response.') and event_type.endswith('.delta'): + # Generic Delta Handling + parts = event_type.split('.') + if len(parts) >= 3: + delta_type = parts[1] + delta = data.get('delta', '') + + output_index = data.get('output_index', len(current_output) - 1) + + if current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + item_type = item.get('type', '') + + # Determine target field and object based on delta_type and item_type + if delta_type == 'function_call_arguments': + key = 'arguments' + if item_type == 'function_call': + # Function call args are usually strings + item[key] = item.get(key, '') + str(delta) + else: + # Generic handling, refined by item type below + pass + + if item_type == 'message': + # Message items: "text"/"output_text" -> "text" + # "reasoning_text" -> Skipped (should use reasoning item) + if delta_type in ['text', 'output_text']: + key = 'text' + elif delta_type in ['reasoning_text', 'reasoning_summary_text']: + # Skip reasoning updates for message items + return new_output, None + else: + key = delta_type + + content_index = data.get('content_index', 0) + if 'content' not in item: + item['content'] = [] + else: + item['content'] = list(item['content']) + content_list = item['content'] + + while len(content_list) <= content_index: + content_list.append({'type': 'text', 'text': ''}) + + # Copy the part to mutate it + part = content_list[content_index].copy() + content_list[content_index] = part + + current_val = part.get(key) + if current_val is None: + # Initialize based on delta type + current_val = {} if isinstance(delta, dict) else '' + + part[key] = deep_merge(current_val, delta) + + elif item_type == 'reasoning': + # Reasoning items: "reasoning_text"/"reasoning_summary_text" -> "text" + # "text"/"output_text" -> Skipped (should use message item) + if delta_type == 'reasoning_summary_text': + # Summary updates -> item['summary'] + key = 'text' + summary_index = data.get('summary_index', 0) + if 'summary' not in item: + item['summary'] = [] + else: + item['summary'] = list(item['summary']) + summary_list = item['summary'] + + while len(summary_list) <= summary_index: + summary_list.append({'type': 'summary_text', 'text': ''}) + + part = summary_list[summary_index].copy() + summary_list[summary_index] = part + + target_val = part.get(key, '') + part[key] = deep_merge(target_val, delta) + + elif delta_type == 'reasoning_text': + # Reasoning body updates -> item['content'] + key = 'text' + content_index = data.get('content_index', 0) + if 'content' not in item: + item['content'] = [] + else: + item['content'] = list(item['content']) + content_list = item['content'] + + while len(content_list) <= content_index: + # Reasoning content parts default to text + content_list.append({'type': 'text', 'text': ''}) + + part = content_list[content_index].copy() + content_list[content_index] = part + + target_val = part.get(key, '') + part[key] = deep_merge(target_val, delta) + + elif delta_type in ['text', 'output_text']: + return new_output, None + else: + # Fallback just in case other deltas target reasoning? + pass + + else: + # Fallback for other item types + if delta_type in ['text', 'output_text']: + key = 'text' + else: + key = delta_type + + current_val = item.get(key) + if current_val is None: + current_val = {} if isinstance(delta, dict) else '' + item[key] = deep_merge(current_val, delta) + + return new_output, None + + elif event_type.startswith('response.') and event_type.endswith('.done'): + # Delta Events: response.content_part.done, response.text.done, etc. + parts = event_type.split('.') + if len(parts) >= 3: + type_name = parts[1] + + # 1. Handle specific Delta "done" signals + if type_name == 'content_part': + # "Signaling that no further changes will occur to a content part" + # If payloads contains the full part, we could update it. + # Usually purely signaling in standard implementation, but we check payload. + part = data.get('part') + output_index = data.get('output_index', len(current_output) - 1) + + if part and current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + + if 'content' in item: + item['content'] = list(item['content']) + content_index = data.get('content_index', len(item['content']) - 1) + if 0 <= content_index < len(item['content']): + item['content'][content_index] = part + return new_output, {} + return current_output, None + + elif type_name == 'reasoning_summary_part': + part = data.get('part') + output_index = data.get('output_index', len(current_output) - 1) + + if part and current_output and 0 <= output_index < len(current_output): + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + + if 'summary' in item: + item['summary'] = list(item['summary']) + summary_index = data.get('summary_index', len(item['summary']) - 1) + if 0 <= summary_index < len(item['summary']): + item['summary'][summary_index] = part + return new_output, {} + return current_output, None + + # 2. Skip Output Item done (handled specifically below) + if type_name == 'output_item': + pass + + # 3. Generic Field Done (text.done, audio.done) + elif type_name not in ['completed', 'failed']: + output_index = data.get('output_index', len(current_output) - 1) + if current_output and 0 <= output_index < len(current_output): + key = ( + 'text' + if type_name + in [ + 'text', + 'output_text', + 'reasoning_text', + 'reasoning_summary_text', + ] + else type_name + ) + if type_name == 'function_call_arguments': + key = 'arguments' + + if key in data: + final_value = data[key] + new_output = list(current_output) + item = new_output[output_index].copy() + new_output[output_index] = item + item_type = item.get('type', '') + + if type_name == 'function_call_arguments': + if item_type == 'function_call': + item['arguments'] = final_value + elif item_type == 'message': + content_index = data.get('content_index', 0) + if 'content' in item: + item['content'] = list(item['content']) + if len(item['content']) > content_index: + part = item['content'][content_index].copy() + item['content'][content_index] = part + part[key] = final_value + elif item_type == 'reasoning': + item['status'] = 'completed' + else: + item[key] = final_value + + return new_output, {} + + return current_output, None + + elif event_type == 'response.output_item.done': + # Delta Event: Output item complete + item = data.get('item') + output_index = data.get('output_index', len(current_output) - 1) + + new_output = list(current_output) + if item and 0 <= output_index < len(current_output): + new_output[output_index] = item + elif item: + new_output.append(item) + return new_output, {} + + elif event_type == 'response.completed': + # State Machine Event: Completed + response_data = data.get('response', {}) + final_output = response_data.get('output') + + new_output = final_output if final_output is not None else current_output + + # Ensure reasoning items are marked as completed in the final output + if new_output: + for item in new_output: + if item.get('type') == 'reasoning' and item.get('status') != 'completed': + item['status'] = 'completed' + + return new_output, { + 'usage': response_data.get('usage'), + 'done': True, + 'response_id': response_data.get('id'), + } + + elif event_type == 'response.in_progress': + # State Machine Event: In Progress + # We could extract metadata if needed, but for now just acknowledge iteration + return current_output, None + + elif event_type == 'response.failed': + # State Machine Event: Failed + error = data.get('response', {}).get('error', {}) + return current_output, {'error': error} + + else: + return current_output, None + + +def get_source_context(sources: list, source_ids: dict = None, include_content: bool = True) -> str: + """ + Build tag context string from citation sources. + """ + context_string = '' + if source_ids is None: + source_ids = {} + for source in sources: + for doc, meta in zip(source.get('document', []), source.get('metadata', [])): + source_id = meta.get('source') or source.get('source', {}).get('id') or 'N/A' + if source_id not in source_ids: + source_ids[source_id] = len(source_ids) + 1 + src_name = source.get('source', {}).get('name') + src_type = source.get('source', {}).get('type') + src_rid = source.get('source', {}).get('id') + body = doc if include_content else '' + context_string += ( + f'{body}\n' + ) + return context_string + + +async def apply_source_context_to_messages( + request: Request, + messages: list, + sources: list, + user_message: str, + include_content: bool = True, +) -> list: + """ + Build source context from citation sources and apply to messages. + Uses RAG template to format context for model consumption. + + When include_content is False, emit tags with id/name but no + document body โ€” useful when the content is already present elsewhere + (e.g. in a tool result message) and only citation markers are needed. + """ + if not sources or not user_message: + return messages + + context = get_source_context(sources, include_content=include_content) + + context = context.strip() + if not context: + return messages + + if RAG_SYSTEM_CONTEXT: + return add_or_update_system_message( + await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), + messages, + append=True, + ) + else: + return add_or_update_user_message( + await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), + messages, + append=False, + ) + + +async def process_tool_result( + request, + tool_function_name, + tool_result, + tool_type, + direct_tool=False, + metadata=None, + user=None, +): + tool_result_embeds = [] + EXTERNAL_TOOL_TYPES = ('external', 'action', 'terminal') + + # Support (HTMLResponse, result_context) tuples: the optional second + # element lets tool authors provide the LLM with actionable context + # about the generated embed instead of the generic fallback message. + result_context = None + if isinstance(tool_result, tuple) and len(tool_result) == 2 and isinstance(tool_result[0], HTMLResponse): + tool_result, result_context = tool_result + + if isinstance(tool_result, HTMLResponse): + content_disposition = tool_result.headers.get('Content-Disposition', '') + if 'inline' in content_disposition: + content = tool_result.body.decode('utf-8', 'replace') + tool_result_embeds.append(content) + + if 200 <= tool_result.status_code < 300: + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } + elif 400 <= tool_result.status_code < 500: + tool_result = { + 'status': 'error', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.', + } + elif 500 <= tool_result.status_code < 600: + tool_result = { + 'status': 'error', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.', + } + else: + tool_result = { + 'status': 'error', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.', + } + else: + tool_result = tool_result.body.decode('utf-8', 'replace') + + elif (tool_type in EXTERNAL_TOOL_TYPES and isinstance(tool_result, tuple)) or ( + direct_tool and isinstance(tool_result, list) and len(tool_result) == 2 + ): + tool_result, tool_response_headers = tool_result + + try: + if not isinstance(tool_response_headers, dict): + tool_response_headers = dict(tool_response_headers) + except Exception as e: + tool_response_headers = {} + log.debug(e) + + if tool_response_headers and isinstance(tool_response_headers, dict): + content_disposition = tool_response_headers.get( + 'Content-Disposition', + tool_response_headers.get('content-disposition', ''), + ) + + if 'inline' in content_disposition: + content_type = tool_response_headers.get( + 'Content-Type', + tool_response_headers.get('content-type', ''), + ) + location = tool_response_headers.get( + 'Location', + tool_response_headers.get('location', ''), + ) + + if 'text/html' in content_type: + # Support (html_content, result_context) nested tuple + result_context = None + html_content = tool_result + if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2: + html_content, result_context = tool_result + + # Display as iframe embed + tool_result_embeds.append(html_content) + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } + elif location: + # Support (html_content, result_context) nested tuple for location embeds + result_context = None + if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2: + _, result_context = tool_result + + tool_result_embeds.append(location) + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } + + tool_result_files = [] + + # Detect base64 image data URIs from tool results (e.g. binary image + # responses from execute_tool_server). Move the data URI to + # tool_result_files and replace tool_result with a text summary. + if isinstance(tool_result, str) and tool_result.startswith('data:image/'): + tool_result_files.append({'type': 'image', 'url': tool_result}) + tool_result = f'{tool_function_name}: Image file read successfully.' + + if isinstance(tool_result, list): + if tool_type == 'mcp': # MCP + tool_response = [] + for item in tool_result: + if isinstance(item, dict): + if item.get('type') == 'text': + text = item.get('text', '') + if isinstance(text, str): + try: + text = json.loads(text) + except json.JSONDecodeError: + pass + tool_response.append(text) + elif item.get('type') in ['image', 'audio']: + file_url = await get_file_url_from_base64( + request, + f'data:{item.get("mimeType")};base64,{item.get("data", item.get("blob", ""))}', + { + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), + 'session_id': metadata.get('session_id', None), + 'result': item, + }, + user, + ) + + tool_result_files.append( + { + 'type': item.get('type', 'data'), + 'url': file_url, + } + ) + elif item.get('type') == 'resource': + resource = item.get('resource', {}) + text = resource.get('text', '') + if isinstance(text, str) and text: + try: + text = json.loads(text) + except json.JSONDecodeError: + pass + tool_response.append(text) + tool_result = tool_response[0] if len(tool_response) == 1 else tool_response + else: # OpenAPI + for item in tool_result: + if isinstance(item, str) and item.startswith('data:'): + tool_result_files.append( + { + 'type': 'data', + 'content': item, + } + ) + tool_result.remove(item) + + if isinstance(tool_result, list): + tool_result = {'results': tool_result} + + if isinstance(tool_result, dict) or isinstance(tool_result, list): + tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False) + + # Safety: ensure tool_result is always a string (or None) to prevent + # downstream TypeError when concatenating (e.g. if an upstream callable + # returned a tuple that was not unpacked by the branches above). + if tool_result is not None and not isinstance(tool_result, str): + if isinstance(tool_result, tuple): + # execute_tool_server returns (data, headers); unpack the data part + tool_result = json.dumps(tool_result[0], indent=2, ensure_ascii=False) if len(tool_result) > 0 else '' + else: + tool_result = str(tool_result) + + return tool_result, tool_result_files, tool_result_embeds + + +async def terminal_event_handler( + tool_function_name: str, + tool_function_params: dict, + tool_result, + event_emitter, +): + """Emit terminal:* events for Open Terminal tools. + + - display_file โ†’ emits 'terminal:display_file' to open the file preview. + - write_file / replace_file_content โ†’ emits 'terminal:write_file' to refresh. + - run_command โ†’ emits 'terminal:run_command' with cwd to refresh if relevant. + """ + if not event_emitter: + return + + if tool_function_name == 'display_file': + path = tool_function_params.get('path', '') + if not path: + return + # Only emit if the file actually exists + parsed = tool_result + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except (json.JSONDecodeError, TypeError): + pass + if isinstance(parsed, dict) and parsed.get('exists') is False: + return + + await event_emitter( + { + 'type': f'terminal:{tool_function_name}', + 'data': {'path': path}, + } + ) + elif tool_function_name in ('write_file', 'replace_file_content'): + path = tool_function_params.get('path', '') + if not path: + return + await event_emitter( + { + 'type': f'terminal:{tool_function_name}', + 'data': {'path': path}, + } + ) + elif tool_function_name == 'run_command': + await event_emitter( + { + 'type': 'terminal:run_command', + 'data': {}, + } + ) + + +async def chat_completion_tools_handler( + request: Request, body: dict, extra_params: dict, user: UserModel, models, tools +) -> tuple[dict, dict]: + async def get_content_from_response(response) -> Optional[str]: + content = None + if hasattr(response, 'body_iterator'): + async for chunk in response.body_iterator: + data = json.loads(chunk.decode('utf-8', 'replace')) + content = data['choices'][0]['message']['content'] + + # Cleanup any remaining background tasks if necessary + if response.background is not None: + await response.background() + else: + content = response['choices'][0]['message']['content'] + return content + + def get_tools_function_calling_payload(messages, task_model_id, content): + user_message = get_last_user_message(messages) + + if user_message and messages and messages[-1]['role'] == 'user': + # Remove the last user message to avoid duplication + messages = messages[:-1] + + recent_messages = messages[-4:] if len(messages) > 4 else messages + chat_history = '\n'.join( + f'{message["role"].upper()}: """{get_content_from_message(message)}"""' for message in recent_messages + ) + + prompt = f'History:\n{chat_history}\nQuery: {user_message}' if chat_history else f'Query: {user_message}' + + return { + 'model': task_model_id, + 'messages': [ + {'role': 'system', 'content': content}, + {'role': 'user', 'content': prompt}, + ], + 'stream': False, + 'metadata': {'task': str(TASKS.FUNCTION_CALLING)}, + } + + event_caller = extra_params['__event_call__'] + event_emitter = extra_params['__event_emitter__'] + metadata = extra_params['__metadata__'] + + task_model_id = get_task_model_id( + body['model'], + request.app.state.config.TASK_MODEL, + request.app.state.config.TASK_MODEL_EXTERNAL, + models, + ) + + skip_files = False + sources = [] + + specs = [tool['spec'] for tool in tools.values()] + tools_specs = json.dumps(specs, ensure_ascii=False) + + if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != '': + template = request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE + else: + template = DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE + + tools_function_calling_prompt = tools_function_calling_generation_template(template, tools_specs) + payload = get_tools_function_calling_payload(body['messages'], task_model_id, tools_function_calling_prompt) + + try: + response = await generate_chat_completion(request, form_data=payload, user=user) + log.debug(f'{response=}') + content = await get_content_from_response(response) + log.debug(f'{content=}') + + if not content: + return body, {} + + try: + content = content[content.find('{') : content.rfind('}') + 1] + if not content: + raise Exception('No JSON object found in the response') + + result = json.loads(content) + + async def tool_call_handler(tool_call): + nonlocal skip_files + + log.debug(f'{tool_call=}') + + tool_function_name = tool_call.get('name', None) + if tool_function_name not in tools: + log.warning(f'Tool "{tool_function_name}" not found') + return + + tool_function_params = tool_call.get('parameters', {}) + + tool = None + tool_type = '' + direct_tool = False + + try: + tool = tools[tool_function_name] + tool_type = tool.get('type', '') + direct_tool = tool.get('direct', False) + + spec = tool.get('spec', {}) + allowed_params = spec.get('parameters', {}).get('properties', {}).keys() + tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params} + + if tool.get('direct', False): + tool_result = await event_caller( + { + 'type': 'execute:tool', + 'data': { + 'id': str(uuid4()), + 'name': tool_function_name, + 'params': tool_function_params, + 'server': tool.get('server', {}), + 'session_id': metadata.get('session_id', None), + }, + } + ) + else: + tool_function = tool['callable'] + tool_result = await tool_function(**tool_function_params) + + except Exception as e: + tool_result = str(e) + + tool_result, tool_result_files, tool_result_embeds = await process_tool_result( + request, + tool_function_name, + tool_result, + tool_type, + direct_tool, + metadata, + user, + ) + + if event_emitter: + await terminal_event_handler( + tool_function_name, + tool_function_params, + tool_result, + event_emitter, + ) + + if tool_result_files: + await event_emitter( + { + 'type': 'files', + 'data': { + 'files': tool_result_files, + }, + } + ) + + if tool_result_embeds: + await event_emitter( + { + 'type': 'embeds', + 'data': { + 'embeds': tool_result_embeds, + }, + } + ) + + if tool_result: + tool = tools[tool_function_name] + tool_id = tool.get('tool_id', '') + + tool_name = f'{tool_id}/{tool_function_name}' if tool_id else f'{tool_function_name}' + + # Citation is enabled for this tool + sources.append( + { + 'source': { + 'name': (f'{tool_name}'), + }, + 'document': [str(tool_result)], + 'metadata': [ + { + 'source': (f'{tool_name}'), + 'parameters': tool_function_params, + } + ], + 'tool_result': True, + } + ) + + if tools[tool_function_name].get('metadata', {}).get('file_handler', False): + skip_files = True + + # check if "tool_calls" in result + if result.get('tool_calls'): + for tool_call in result.get('tool_calls'): + await tool_call_handler(tool_call) + else: + await tool_call_handler(result) + + except Exception as e: + log.debug(f'Error: {e}') + content = None + except Exception as e: + log.debug(f'Error: {e}') + content = None + + log.debug(f'tool_contexts: {sources}') + + if skip_files and 'files' in body.get('metadata', {}): + del body['metadata']['files'] + + return body, {'sources': sources} + + +async def chat_memory_handler(request: Request, form_data: dict, extra_params: dict, user): + try: + results = await query_memory( + request, + QueryMemoryForm( + **{ + 'content': get_last_user_message(form_data['messages']) or '', + 'k': 3, + } + ), + user, + ) + except Exception as e: + log.debug(e) + results = None + + user_context = '' + if results and hasattr(results, 'documents'): + if results.documents and len(results.documents) > 0: + for doc_idx, doc in enumerate(results.documents[0]): + created_at_date = 'Unknown Date' + + if results.metadatas[0][doc_idx].get('created_at'): + created_at_timestamp = results.metadatas[0][doc_idx]['created_at'] + created_at_date = time.strftime('%Y-%m-%d', time.localtime(created_at_timestamp)) + + user_context += f'{doc_idx + 1}. [{created_at_date}] {doc}\n' + + form_data['messages'] = add_or_update_system_message( + f'User Context:\n{user_context}\n', form_data['messages'], append=True + ) + + return form_data + + +async def chat_web_search_handler(request: Request, form_data: dict, extra_params: dict, user): + event_emitter = extra_params['__event_emitter__'] + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'Searching the web', + 'done': False, + }, + } + ) + + messages = form_data['messages'] + user_message = get_last_user_message(messages) + + queries = [] + try: + res = await generate_queries( + request, + { + 'model': form_data['model'], + 'messages': messages, + 'prompt': user_message, + 'type': 'web_search', + 'chat_id': extra_params.get('__chat_id__'), + }, + user, + ) + + # generate_queries returns a JSONResponse on error (e.g. model not + # found, chat completion failure). Extract the error detail and + # re-raise so the outer except block falls back to using the raw + # user message as the search query. + if isinstance(res, JSONResponse): + try: + error_body = json.loads(res.body) + detail = error_body.get('detail', 'Query generation failed') + except Exception: + detail = 'Query generation failed' + raise Exception(detail) + + response = res['choices'][0]['message']['content'] + + try: + bracket_start = response.rfind('{') + bracket_end = response.rfind('}') + 1 + + if bracket_start == -1 or bracket_end == -1: + raise Exception('No JSON object found in the response') + + response = response[bracket_start:bracket_end] + queries = json.loads(response) + queries = queries.get('queries', []) + except Exception as e: + queries = [response] + + if ENABLE_QUERIES_CACHE: + request.state.cached_queries = queries + + except Exception as e: + log.exception(e) + queries = [user_message or ''] + + # Check if generated queries are empty + if len(queries) == 1 and queries[0].strip() == '': + queries = [user_message or ''] + + # Check if queries are not found + if len(queries) == 0: + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'No search query generated', + 'done': True, + }, + } + ) + return form_data + + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'web_search_queries_generated', + 'queries': queries, + 'done': False, + }, + } + ) + + try: + results = await process_web_search( + request, + SearchForm(queries=queries), + user=user, + ) + + if results: + files = form_data.get('files', []) + + if results.get('collection_names'): + for col_idx, collection_name in enumerate(results.get('collection_names')): + files.append( + { + 'collection_name': collection_name, + 'name': ', '.join(queries), + 'type': 'web_search', + 'urls': results['filenames'], + 'queries': queries, + } + ) + elif results.get('docs'): + # Invoked when bypass embedding and retrieval is set to True + docs = results['docs'] + files.append( + { + 'docs': docs, + 'name': ', '.join(queries), + 'type': 'web_search', + 'urls': results['filenames'], + 'queries': queries, + } + ) + + form_data['files'] = files + + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'Searched {{count}} sites', + 'urls': results['filenames'], + 'items': results.get('items', []), + 'done': True, + }, + } + ) + else: + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'No search results found', + 'done': True, + 'error': True, + }, + } + ) + + except Exception as e: + log.exception(e) + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'An error occurred while searching the web', + 'queries': queries, + 'done': True, + 'error': True, + }, + } + ) + + return form_data + + +def get_images_from_messages(message_list): + images = [] + + for message in reversed(message_list): + message_images = [] + for file in message.get('files', []): + if file.get('type') == 'image': + message_images.append(file.get('url')) + elif file.get('content_type', '').startswith('image/'): + message_images.append(file.get('url')) + + if message_images: + images.append(message_images) + + return images + + +async def get_image_urls(delta_images, request, metadata, user) -> list[str]: + if not isinstance(delta_images, list): + return [] + + image_urls = [] + for img in delta_images: + if not isinstance(img, dict) or img.get('type') != 'image_url': + continue + + url = img.get('image_url', {}).get('url') + if not url: + continue + + if url.startswith('data:image/png;base64'): + url = await get_image_url_from_base64(request, url, metadata, user) + + image_urls.append(url) + + return image_urls + + +async def add_file_context(messages: list, chat_id: str, user) -> list: + """ + Add file URLs to messages for native function calling. + """ + if not chat_id or chat_id.startswith('local:') or chat_id.startswith('channel:'): + return messages + + chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) + if not chat: + return messages + + history = chat.chat.get('history', {}) + stored_messages = get_message_list(history.get('messages', {}), history.get('currentId')) + + def format_file_tag(file): + attrs = f'type="{file.get("type", "file")}" url="{file["url"]}"' + if file.get('content_type'): + attrs += f' content_type="{file["content_type"]}"' + if file.get('name'): + attrs += f' name="{file["name"]}"' + return f'' + + # Pair only user-role messages from both lists to avoid misalignment. + # After process_messages_with_output(), assistant messages with tool calls + # are expanded into multiple messages (assistant + tool results), making + # the payload message list longer than the stored message list. A naive + # positional zip() would pair user messages with wrong stored messages, + # causing later images to lose their file context (see #21878). + user_messages = [m for m in messages if m.get('role') == 'user'] + stored_user_messages = [m for m in stored_messages if m.get('role') == 'user'] + + for message, stored_message in zip(user_messages, stored_user_messages): + files_with_urls = [ + file + for file in stored_message.get('files', []) + if file.get('url') and not file.get('url').startswith('data:') + ] + if not files_with_urls: + continue + + file_tags = [format_file_tag(file) for file in files_with_urls] + file_context = '\n' + '\n'.join(file_tags) + '\n\n\n' + + content = message.get('content', '') + if isinstance(content, list): + message['content'] = [{'type': 'text', 'text': file_context}] + content + else: + message['content'] = file_context + content + + return messages + + +async def chat_image_generation_handler(request: Request, form_data: dict, extra_params: dict, user): + metadata = extra_params.get('__metadata__', {}) + chat_id = metadata.get('chat_id', None) + __event_emitter__ = extra_params.get('__event_emitter__', None) + + if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: + return form_data + + if chat_id.startswith('local:') or chat_id.startswith('channel:'): + message_list = form_data.get('messages', []) + else: + chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) + await __event_emitter__( + { + 'type': 'status', + 'data': {'description': 'Creating image', 'done': False}, + } + ) + + messages_map = chat.chat.get('history', {}).get('messages', {}) + message_id = chat.chat.get('history', {}).get('currentId') + message_list = get_message_list(messages_map, message_id) + + user_message = get_last_user_message(message_list) + + prompt = user_message + message_images = get_images_from_messages(message_list) + + # Limit to first 2 sets of images + # We may want to change this in the future to allow more images + input_images = [] + for idx, images in enumerate(message_images): + if idx >= 2: + break + for image in images: + input_images.append(image) + + system_message_content = '' + + if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT: + # Edit image(s) + try: + images = await image_edits( + request=request, + form_data=EditImageForm(**{'prompt': prompt, 'image': input_images}), + metadata={ + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), + }, + user=user, + ) + + await __event_emitter__( + { + 'type': 'status', + 'data': {'description': 'Image created', 'done': True}, + } + ) + + await __event_emitter__( + { + 'type': 'files', + 'data': { + 'files': [ + { + 'type': 'image', + 'url': image['url'], + } + for image in images + ] + }, + } + ) + + system_message_content = 'The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated.' + except Exception as e: + log.debug(e) + + error_message = '' + if isinstance(e, HTTPException): + if e.detail and isinstance(e.detail, dict): + error_message = e.detail.get('message', str(e.detail)) + else: + error_message = str(e.detail) + + await __event_emitter__( + { + 'type': 'status', + 'data': { + 'description': f'An error occurred while generating an image', + 'done': True, + }, + } + ) + + system_message_content = f'Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}' + + else: + # Create image(s) + if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION: + try: + res = await generate_image_prompt( + request, + { + 'model': form_data['model'], + 'messages': form_data['messages'], + 'chat_id': metadata.get('chat_id'), + }, + user, + ) + + # Handle JSONResponse from error paths + if isinstance(res, JSONResponse): + try: + error_body = json.loads(res.body) + detail = error_body.get('detail', 'Image prompt generation failed') + except Exception: + detail = 'Image prompt generation failed' + raise Exception(detail) + + response = res['choices'][0]['message']['content'] + + try: + bracket_start = response.rfind('{') + bracket_end = response.rfind('}') + 1 + + if bracket_start == -1 or bracket_end == -1: + raise Exception('No JSON object found in the response') + + response = response[bracket_start:bracket_end] + response = json.loads(response) + prompt = response.get('prompt', []) + except Exception as e: + prompt = user_message + + except Exception as e: + log.exception(e) + prompt = user_message + + try: + images = await image_generations( + request=request, + form_data=CreateImageForm(**{'prompt': prompt}), + metadata={ + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), + }, + user=user, + ) + + await __event_emitter__( + { + 'type': 'status', + 'data': {'description': 'Image created', 'done': True}, + } + ) + + await __event_emitter__( + { + 'type': 'files', + 'data': { + 'files': [ + { + 'type': 'image', + 'url': image['url'], + } + for image in images + ] + }, + } + ) + + system_message_content = 'The requested image has been created by the system successfully and is now being shown to the user. Let the user know that the image they requested has been generated and is now shown in the chat.' + except Exception as e: + log.debug(e) + + error_message = '' + if isinstance(e, HTTPException): + if e.detail and isinstance(e.detail, dict): + error_message = e.detail.get('message', str(e.detail)) + else: + error_message = str(e.detail) + + await __event_emitter__( + { + 'type': 'status', + 'data': { + 'description': f'An error occurred while generating an image', + 'done': True, + }, + } + ) + + system_message_content = f'Image generation was attempted but failed because of an error. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}' + + if system_message_content: + form_data['messages'] = add_or_update_system_message(system_message_content, form_data['messages']) + + return form_data + + +async def chat_completion_files_handler( + request: Request, body: dict, extra_params: dict, user: UserModel +) -> tuple[dict, dict[str, list]]: + __event_emitter__ = extra_params['__event_emitter__'] + sources = [] + + if files := body.get('metadata', {}).get('files', None): + # Check if all files are in full context mode + all_full_context = all(item.get('context') == 'full' for item in files) + + queries = [] + if not all_full_context: + try: + queries_response = await generate_queries( + request, + { + 'model': body['model'], + 'messages': body['messages'], + 'type': 'retrieval', + 'chat_id': body.get('metadata', {}).get('chat_id'), + }, + user, + ) + queries_response = queries_response['choices'][0]['message']['content'] + + try: + bracket_start = queries_response.rfind('{') + bracket_end = queries_response.rfind('}') + 1 + + if bracket_start == -1 or bracket_end == -1: + raise Exception('No JSON object found in the response') + + queries_response = queries_response[bracket_start:bracket_end] + queries_response = json.loads(queries_response) + except Exception as e: + queries_response = {'queries': [queries_response]} + + queries = queries_response.get('queries', []) + except Exception: + pass + + await __event_emitter__( + { + 'type': 'status', + 'data': { + 'action': 'queries_generated', + 'queries': queries, + 'done': False, + }, + } + ) + + if len(queries) == 0: + queries = [get_last_user_message(body['messages']) or ''] + + try: + # Directly await async get_sources_from_items (no thread needed - fully async now) + sources = await get_sources_from_items( + request=request, + items=files, + queries=queries, + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user + ), + k=request.app.state.config.TOP_K, + reranking_function=( + (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) + if request.app.state.RERANKING_FUNCTION + else None + ), + k_reranker=request.app.state.config.TOP_K_RERANKER, + r=request.app.state.config.RELEVANCE_THRESHOLD, + hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, + hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + full_context=all_full_context or request.app.state.config.RAG_FULL_CONTEXT, + user=user, + ) + except Exception as e: + log.exception(e) + + log.debug(f'rag_contexts:sources: {sources}') + + unique_ids = set() + for source in sources or []: + if not source or len(source.keys()) == 0: + continue + + documents = source.get('document') or [] + metadatas = source.get('metadata') or [] + src_info = source.get('source') or {} + + for index, _ in enumerate(documents): + metadata = metadatas[index] if index < len(metadatas) else None + _id = (metadata or {}).get('source') or (src_info or {}).get('id') or 'N/A' + unique_ids.add(_id) + + sources_count = len(unique_ids) + await __event_emitter__( + { + 'type': 'status', + 'data': { + 'action': 'sources_retrieved', + 'count': sources_count, + 'done': True, + }, + } + ) + + return body, {'sources': sources} + + +def apply_params_to_form_data(form_data, model): + params = form_data.pop('params', {}) + custom_params = params.pop('custom_params', {}) + + open_webui_params = { + 'stream_response': bool, + 'stream_delta_chunk_size': int, + 'function_calling': str, + 'reasoning_tags': list, + 'system': str, + } + + for key in list(params.keys()): + if key in open_webui_params: + del params[key] + + if custom_params: + # Attempt to parse custom_params if they are strings + for key, value in custom_params.items(): + if isinstance(value, str): + try: + # Attempt to parse the string as JSON + custom_params[key] = json.loads(value) + except json.JSONDecodeError: + # If it fails, keep the original string + pass + + # If custom_params are provided, merge them into params + params = deep_update(params, custom_params) + + if model.get('owned_by') == 'ollama': + # Ollama specific parameters + form_data['options'] = params + else: + if isinstance(params, dict): + for key, value in params.items(): + if value is not None: + form_data[key] = value + + if 'logit_bias' in params and params['logit_bias'] is not None: + try: + logit_bias = convert_logit_bias_input_to_json(params['logit_bias']) + + if logit_bias: + form_data['logit_bias'] = json.loads(logit_bias) + except Exception as e: + log.exception(f'Error parsing logit_bias: {e}') + + return form_data + + +async def convert_url_images_to_base64(form_data, user=None): + messages = form_data.get('messages', []) + + for message in messages: + content = message.get('content') + if not isinstance(content, list): + continue + + new_content = [] + + for item in content: + if not isinstance(item, dict) or item.get('type') != 'image_url': + new_content.append(item) + continue + + image_url = item.get('image_url', {}).get('url', '') + if image_url.startswith('data:image/'): + new_content.append(item) + continue + + try: + base64_data = await get_image_base64_from_url(image_url, user=user) + if base64_data: + new_content.append( + { + 'type': 'image_url', + 'image_url': {'url': base64_data}, + } + ) + else: + new_content.append(item) + except Exception as e: + log.debug(f'Error converting image URL to base64: {e}') + new_content.append(item) + + message['content'] = new_content + + return form_data + + +async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]]: + """ + Load the message chain from DB up to message_id, + keeping only LLM-relevant fields (role, content, output). + """ + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) + if not messages_map: + return None + + db_messages = get_message_list(messages_map, message_id) + if not db_messages: + return None + + return [{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files')} for msg in db_messages] + + +def get_reasoning_format(model: dict) -> str | None: + """ + Determine how reasoning should be included in reconstructed messages. + + Returns: + 'think_tags': Ollama expects tags in content. + 'reasoning_content': llama.cpp supports reasoning_content as a top-level field. + None: skip reasoning (safe default for strict providers). + """ + provider = model.get('provider', '') + if provider == 'ollama': + return 'think_tags' + if provider == 'llama.cpp': + return 'reasoning_content' + return None + + +def process_messages_with_output( + messages: list[dict], + reasoning_format: str | None = None, +) -> list[dict]: + """ + Process messages with OR-aligned output items for LLM consumption. + + For assistant messages with 'output' field, produces properly formatted + OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM. + """ + processed = [] + + for message in messages: + if message.get('role') == 'assistant' and message.get('output'): + # Use output items for clean OpenAI-format messages + output_messages = convert_output_to_messages( + message['output'], + raw=True, + reasoning_format=reasoning_format, + ) + if output_messages: + processed.extend(output_messages) + continue + + # Strip 'output' field before adding (LLM shouldn't see it) + clean_message = {k: v for k, v in message.items() if k != 'output'} + processed.append(clean_message) + + return processed + + +SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)\|?[^>]*>') + + +def _get_text_parts(message: dict) -> list[str]: + """Return all text segments from a message's content.""" + content = message.get('content') + if isinstance(content, str): + return [content] + if isinstance(content, list): + return [p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text'] + return [] + + +def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]: + """Extract skill IDs from <$skillId|label> mention tags in messages.""" + ids: set[str] = set() + for message in messages: + for text in _get_text_parts(message): + ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text)) + return ids + + +def strip_skill_mentions(messages: list[dict]) -> None: + """Replace <$skillId|label> mention tags with the label in message content in-place.""" + strip_re = re.compile(r'<\$[^|>]+\|?([^>]*)>') + for message in messages: + content = message.get('content') + if isinstance(content, str) and strip_re.search(content): + message['content'] = strip_re.sub(r'\1', content).strip() + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get('type') == 'text': + text = part.get('text', '') + if strip_re.search(text): + part['text'] = strip_re.sub(r'\1', text).strip() + + +async def connect_mcp_server( + request, + server_id: str, + user, + metadata: dict, + extra_params: dict, +) -> tuple[MCPClient, list[dict]] | None: + """Resolve an MCP server connection, authenticate, and return (client, tool_specs). + + Returns None if the server is not found or access is denied. + """ + mcp_server_connection = None + for server_connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: + if server_connection.get('type', '') == 'mcp' and server_connection.get('info', {}).get('id') == server_id: + mcp_server_connection = server_connection + break + + if not mcp_server_connection: + log.error(f'MCP server with id {server_id} not found') + return None + + if not await has_connection_access(user, mcp_server_connection): + log.warning(f'Access denied to MCP server {server_id} for user {user.id}') + return None + + headers, _ = await build_tool_server_headers( + mcp_server_connection, + request, + user, + server_id=server_id, + metadata=metadata, + extra_params=extra_params, + ) + + client = MCPClient() + await client.connect( + url=mcp_server_connection.get('url', ''), + headers=headers if headers else None, + ) + + function_name_filter_list = mcp_server_connection.get('config', {}).get('function_name_filter_list', '') + if isinstance(function_name_filter_list, str): + function_name_filter_list = function_name_filter_list.split(',') + + tool_specs = await client.list_tool_specs() + if function_name_filter_list: + tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)] + + return client, tool_specs + + +async def process_chat_payload(request, form_data, user, metadata, model): + # Ensure chat_id is always a string โ€” external API clients may omit it. + if not isinstance(metadata.get('chat_id'), str): + metadata['chat_id'] = '' + + # Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation + # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling + # -> Chat Files + + # Arena model resolution โ€” pick the sub-model now so all downstream + # processing (knowledge, capabilities, tools, params) uses its settings + # instead of the empty arena wrapper. + if model.get('owned_by') == 'arena': + arena_model_ids = model.get('info', {}).get('meta', {}).get('model_ids') + arena_filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode') + if arena_model_ids and arena_filter_mode == 'exclude': + arena_model_ids = [ + available_model['id'] + for available_model in request.app.state.MODELS.values() + if available_model.get('owned_by') != 'arena' and available_model['id'] not in arena_model_ids + ] + + if isinstance(arena_model_ids, list) and arena_model_ids: + selected_model_id = random.choice(arena_model_ids) + else: + arena_model_ids = [ + available_model['id'] + for available_model in request.app.state.MODELS.values() + if available_model.get('owned_by') != 'arena' + ] + selected_model_id = random.choice(arena_model_ids) + + selected_model = request.app.state.MODELS.get(selected_model_id) + if selected_model: + model = selected_model + form_data['model'] = selected_model_id + metadata['selected_model_id'] = selected_model_id + + form_data = apply_params_to_form_data(form_data, model) + log.debug(f'form_data: {form_data}') + + # Guided regeneration: extract before it reaches the LLM provider + regeneration_prompt = form_data.pop('regeneration_prompt', None) + + # Load messages from DB when available โ€” DB preserves structured 'output' items + # which the frontend strips, causing tool calls to be merged into content. + chat_id = metadata.get('chat_id') + user_message_id = metadata.get('user_message_id') + + if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'): + db_messages = await load_messages_from_db(chat_id, user_message_id) + if db_messages: + # Continue: frontend sends assistant_message_id when continuing + # an existing response. Load its content so the LLM sees prior output. + assistant_message_id = metadata.get('assistant_message_id') + if assistant_message_id: + assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id) + if assistant_message and (assistant_message.get('content') or assistant_message.get('output')): + db_messages.append( + {k: v for k, v in assistant_message.items() if k in ('role', 'content', 'output', 'files')} + ) + + system_message = get_system_message(form_data.get('messages', [])) + form_data['messages'] = [system_message, *db_messages] if system_message else db_messages + + # Inject image files into content as image_url parts (mirrors frontend logic) + for message in form_data['messages']: + image_files = [ + f + for f in message.get('files', []) + if f.get('type') == 'image' or (f.get('content_type') or '').startswith('image/') + ] + if message.get('role') == 'user' and image_files: + text_content = message.get('content', '') + if isinstance(text_content, str): + message['content'] = [ + {'type': 'text', 'text': text_content}, + *[ + { + 'type': 'image_url', + 'image_url': {'url': f['url']}, + } + for f in image_files + if f.get('url') + ], + ] + # Strip files field โ€” it's been incorporated into content + message.pop('files', None) + + if regeneration_prompt: + form_data['messages'].append({'role': 'user', 'content': regeneration_prompt}) + + # Process messages with OR-aligned output items for clean LLM messages + form_data['messages'] = process_messages_with_output( + form_data.get('messages', []), + reasoning_format=get_reasoning_format(model), + ) + + system_message = get_system_message(form_data.get('messages', [])) + if system_message: # Chat Controls/User Settings + try: + form_data = await apply_system_prompt_to_body( + system_message.get('content'), form_data, metadata, user, replace=True + ) # Required to handle system prompt variables + except Exception: + pass + + form_data = await convert_url_images_to_base64(form_data, user=user) + + event_emitter = await get_event_emitter(metadata) + event_caller = await get_event_call(metadata) + + extra_params = { + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__oauth_token__': await get_system_oauth_token(request, user), + '__request__': request, + '__model__': model, + '__chat_id__': metadata.get('chat_id'), + '__message_id__': metadata.get('message_id'), + } + # Initialize events to store additional event to be sent to the client + # Initialize contexts and citation + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): + models = { + request.state.model['id']: request.state.model, + } + else: + models = request.app.state.MODELS + + task_model_id = get_task_model_id( + form_data['model'], + request.app.state.config.TASK_MODEL, + request.app.state.config.TASK_MODEL_EXTERNAL, + models, + ) + + events = [] + sources = [] + + # Folder "Project" handling + # Check if the request has chat_id and is inside of a folder + # Uses lightweight column query โ€” only fetches folder_id, not the full chat JSON blob + chat_id = metadata.get('chat_id', None) + folder_id = None + if chat_id and user: + folder_id = await Chats.get_chat_folder_id(chat_id, user.id) + + # Fallback: use folder_id from metadata (temporary chats have no DB record) + if not folder_id: + folder_id = metadata.get('folder_id', None) + + if folder_id and user: + folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) + + if folder and folder.data: + if 'system_prompt' in folder.data: + form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user) + if 'files' in folder.data: + # Defensive: filter to entries the caller can still read. + allowed_files = await get_accessible_folder_files(folder.data['files'], user) + if metadata.get('params', {}).get('function_calling') != 'native': + form_data['files'] = [ + *allowed_files, + *form_data.get('files', []), + ] + else: + # Native FC: skip RAG injection, builtin tools + # will read folder knowledge from metadata. + metadata['folder_knowledge'] = allowed_files + + # Model "Knowledge" handling + user_message = get_last_user_message(form_data['messages']) + model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', False) + + if model_knowledge and metadata.get('params', {}).get('function_calling') != 'native': + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'knowledge_search', + 'query': user_message, + 'done': False, + }, + } + ) + + knowledge_files = [] + for item in model_knowledge: + if item.get('collection_name'): + knowledge_files.append( + { + 'id': item.get('collection_name'), + 'name': item.get('name'), + 'legacy': True, + } + ) + elif item.get('collection_names'): + knowledge_files.append( + { + 'name': item.get('name'), + 'type': 'collection', + 'collection_names': item.get('collection_names'), + 'legacy': True, + } + ) + else: + knowledge_files.append(item) + + files = form_data.get('files', []) + files.extend(knowledge_files) + form_data['files'] = files + + variables = form_data.pop('variables', None) + payload_tools = form_data.get('tools', None) # snapshot before filters + + # Process the form_data through the pipeline + try: + form_data = await process_pipeline_inlet_filter(request, form_data, user, models) + except Exception as e: + raise e + + try: + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) + + form_data, flags = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='inlet', + form_data=form_data, + extra_params=extra_params, + ) + except Exception as e: + raise Exception(f'{e}') + + features = form_data.pop('features', None) or {} + extra_params['__features__'] = features + if features: + if 'voice' in features and features['voice']: + if getattr(request.app.state.config, 'ENABLE_VOICE_MODE_PROMPT', True): + if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE: + template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE + else: + template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE + + form_data['messages'] = add_or_update_system_message( + template, + form_data['messages'], + ) + + if 'memory' in features and features['memory']: + # Skip forced memory injection when native FC is enabled - model can use memory tools + if metadata.get('params', {}).get('function_calling') != 'native': + form_data = await chat_memory_handler(request, form_data, extra_params, user) + + if 'web_search' in features and features['web_search']: + # Skip forced RAG web search when native FC is enabled - model can use web_search tool + if metadata.get('params', {}).get('function_calling') != 'native': + form_data = await chat_web_search_handler(request, form_data, extra_params, user) + + if 'image_generation' in features and features['image_generation']: + # Skip forced image generation when native FC is enabled - model can use generate_image tool + if metadata.get('params', {}).get('function_calling') != 'native': + form_data = await chat_image_generation_handler(request, form_data, extra_params, user) + + if 'code_interpreter' in features and features['code_interpreter']: + engine = getattr(request.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') + + # Skip XML-tag prompt injection when native FC is enabled โ€” + # execute_code will be injected as a builtin tool instead + if metadata.get('params', {}).get('function_calling') != 'native': + prompt = ( + request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE + if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != '' + else DEFAULT_CODE_INTERPRETER_PROMPT + ) + + # Append filesystem awareness only for pyodide engine + if engine != 'jupyter': + prompt += CODE_INTERPRETER_PYODIDE_PROMPT + + form_data['messages'] = add_or_update_user_message( + prompt, + form_data['messages'], + ) + else: + # Native FC: tool docstring can't be dynamic, so inject + # filesystem context into the system message for pyodide + # engine. Appending to the system prompt (instead of the + # user message) keeps it in the stable cached prefix so + # providers with prefix caching don't re-bill the full + # conversation on every turn. + if engine != 'jupyter': + form_data['messages'] = add_or_update_system_message( + CODE_INTERPRETER_PYODIDE_PROMPT, + form_data['messages'], + append=True, + ) + + tool_ids = form_data.pop('tool_ids', None) + terminal_id = form_data.pop('terminal_id', None) + files = form_data.pop('files', None) + form_data.pop('folder_id', None) + + # If the original caller provided tools, use them as-is (skip resolution). + # Otherwise, save any tools that filter inlets added for merging later. + inlet_filter_tools = None if payload_tools else form_data.get('tools', None) + + # Skills โ€” extract IDs from message content (<$skillId|label> tags) so + # persisted chats work without relying on the frontend to send skill_ids. + user_skill_ids = set(form_data.pop('skill_ids', None) or []) + user_skill_ids |= extract_skill_ids_from_messages(form_data.get('messages', [])) + model_skill_ids = set(model.get('info', {}).get('meta', {}).get('skillIds', [])) + + all_skill_ids = user_skill_ids | model_skill_ids + available_skills = [] + if all_skill_ids: + from open_webui.models.skills import Skills as SkillsModel + + accessible_skill_ids = {s.id for s in await SkillsModel.get_skills_by_user_id(user.id, 'read')} + available_skills = [] + for sid in all_skill_ids: + if sid in accessible_skill_ids: + s = await SkillsModel.get_skill_by_id(sid) + if s and s.is_active: + available_skills.append(s) + + skill_descriptions = '' + for skill in available_skills: + if skill.id in user_skill_ids: + # User-selected: inject full content + form_data['messages'] = add_or_update_system_message( + f'\n{skill.content}\n', + form_data['messages'], + append=True, + ) + else: + # Model-attached: name+description only + skill_descriptions += f'\n{skill.id}\n{skill.name}\n{skill.description or ""}\n\n' + + if skill_descriptions: + form_data['messages'] = add_or_update_system_message( + f'\n{skill_descriptions}', + form_data['messages'], + append=True, + ) + + # Strip <$skillId|label> mention tags so the model doesn't see raw markup. + strip_skill_mentions(form_data.get('messages', [])) + + prompt = get_last_user_message(form_data['messages']) + + # Guard against empty user message after skill mention stripping. + # When a user selects a skill ($skill-name) without typing additional text, + # the stripped result is an empty string which causes 400 errors on providers + # that reject empty content blocks (e.g. AWS Bedrock ConverseStream). + if not prompt or not prompt.strip(): + fallback = ', '.join(s.name for s in available_skills) + if fallback: + set_last_user_message_content(fallback, form_data['messages']) + prompt = fallback + # TODO: re-enable URL extraction from prompt + # urls = [] + # if prompt and len(prompt or "") < 500 and (not files or len(files) == 0): + # urls = extract_urls(prompt) + + if files: + if not files: + files = [] + + for file_item in files: + if file_item.get('type', 'file') == 'folder': + # Get folder files + folder_id = file_item.get('id', None) + if folder_id: + folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) + if folder and folder.data and 'files' in folder.data: + files = [f for f in files if f.get('id', None) != folder_id] + files = [*files, *await get_accessible_folder_files(folder.data['files'], user)] + + # files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]] + # Remove duplicate files based on their content + files = list({json.dumps(f, sort_keys=True): f for f in files}.values()) + + metadata = { + **metadata, + 'model_id': form_data.get('model'), + 'tool_ids': tool_ids, + 'terminal_id': terminal_id, + 'files': files, + } + form_data['metadata'] = metadata + + # When the caller provides an explicit OpenAI-style `tools` array in the + # request body, skip all server-side tool resolution and pass the caller's + # tools through to the model unchanged. + if not payload_tools: + # Server side tools + tool_ids = metadata.get('tool_ids', None) + # Client side tools + direct_tool_servers = metadata.get('tool_servers', None) + + log.debug(f'{tool_ids=}') + log.debug(f'{direct_tool_servers=}') + + tools_dict = {} + + mcp_clients = {} + mcp_tools_dict = {} + + if tool_ids: + for tool_id in tool_ids: + if tool_id.startswith('server:mcp:'): + try: + server_id = tool_id[len('server:mcp:') :] + + result = await connect_mcp_server( + request, + server_id, + user, + metadata, + extra_params, + ) + if result is None: + continue + + client, tool_specs = result + mcp_clients[server_id] = client + + for tool_spec in tool_specs: + + async def make_tool_function(client, function_name): + async def tool_function(**kwargs): + return await client.call_tool( + function_name, + function_args=kwargs, + ) + + return tool_function + + tool_function = await make_tool_function(client, tool_spec['name']) + + mcp_tools_dict[f'{server_id}_{tool_spec["name"]}'] = { + 'spec': { + **tool_spec, + 'name': f'{server_id}_{tool_spec["name"]}', + }, + 'callable': tool_function, + 'type': 'mcp', + 'client': client, + 'direct': False, + } + except Exception as e: + log.debug(e) + if event_emitter: + await event_emitter( + { + 'type': 'chat:message:error', + 'data': {'error': {'content': f"Failed to connect to MCP server '{server_id}'"}}, + } + ) + continue + + tools_dict = await get_tools( + request, + tool_ids, + user, + { + **extra_params, + '__model__': models[task_model_id], + '__messages__': form_data['messages'], + '__files__': metadata.get('files', []), + }, + ) + + if mcp_tools_dict: + tools_dict = {**tools_dict, **mcp_tools_dict} + + # Resolve terminal tools if terminal_id is set (outside tool_ids check + # so system terminals work even when no other tools are selected) + terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True) + if terminal_id and terminal_capability: + try: + terminal_result = await get_terminal_tools( + request, + terminal_id, + user, + extra_params, + ) + if isinstance(terminal_result, tuple): + terminal_tools, system_prompt = terminal_result + else: + terminal_tools = terminal_result + system_prompt = None + if terminal_tools: + tools_dict = {**tools_dict, **terminal_tools} + if system_prompt: + form_data['messages'] = add_or_update_system_message( + system_prompt, + form_data['messages'], + append=True, + ) + except Exception as e: + log.exception(e) + + if direct_tool_servers: + for tool_server in direct_tool_servers: + system_prompt = tool_server.pop('system_prompt', None) + if system_prompt: + form_data['messages'] = add_or_update_system_message( + system_prompt, + form_data['messages'], + append=True, + ) + + tool_specs = tool_server.pop('specs', []) + + for tool in tool_specs: + tools_dict[tool['name']] = { + 'spec': tool, + 'direct': True, + 'server': tool_server, + } + + if mcp_clients: + metadata['mcp_clients'] = mcp_clients + + # Inject builtin tools for native function calling based on enabled features and model capability + # Check if builtin_tools capability is enabled for this model (defaults to True if not specified) + builtin_tools_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( + 'builtin_tools', True + ) + if metadata.get('params', {}).get('function_calling') == 'native' and builtin_tools_enabled: + # Add file context to user messages + chat_id = metadata.get('chat_id') + form_data['messages'] = await add_file_context(form_data.get('messages', []), chat_id, user) + builtin_tools = await get_builtin_tools( + request, + { + **extra_params, + '__event_emitter__': event_emitter, + '__skill_ids__': [s.id for s in available_skills if s.id not in user_skill_ids], + }, + features, + model, + ) + for name, tool_dict in builtin_tools.items(): + if name not in tools_dict: + tools_dict[name] = tool_dict + + if tools_dict: + # Always store resolved tools in metadata so downstream consumers + # (e.g. pipe functions) can access all tools including MCP and builtins. + metadata['tools'] = tools_dict + + if metadata.get('params', {}).get('function_calling') == 'native': + # If the function calling is native, then call the tools function calling handler + form_data['tools'] = [ + {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() + ] + if inlet_filter_tools: + form_data['tools'].extend(inlet_filter_tools) + else: + # If the function calling is not native, then call the tools function calling handler + try: + form_data, flags = await chat_completion_tools_handler( + request, form_data, extra_params, user, models, tools_dict + ) + sources.extend(flags.get('sources', [])) + except Exception as e: + log.exception(e) + + # Check if file context extraction is enabled for this model (default True) + file_context_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('file_context', True) + + if file_context_enabled: + try: + form_data, flags = await chat_completion_files_handler(request, form_data, extra_params, user) + sources.extend(flags.get('sources', [])) + except Exception as e: + log.exception(e) + + # Save the pre-RAG message state so the native tool call loop can + # restore to the true original (before file-source injection) rather + # than a snapshot that already has the RAG template baked in. + system_message = get_system_message(form_data['messages']) + metadata['system_prompt'] = get_content_from_message(system_message) if system_message else None + metadata['user_prompt'] = get_last_user_message(form_data['messages']) + metadata['sources'] = sources[:] if sources else [] + + # If context is not empty, insert it into the messages + if sources and prompt: + form_data['messages'] = await apply_source_context_to_messages(request, form_data['messages'], sources, prompt) + + # If there are citations, add them to the data_items + sources = [ + source + for source in sources + if source.get('source', {}).get('name', '') or source.get('source', {}).get('id', '') + ] + + if len(sources) > 0: + events.append({'sources': sources}) + + if model_knowledge: + await event_emitter( + { + 'type': 'status', + 'data': { + 'action': 'knowledge_search', + 'query': user_message, + 'done': True, + 'hidden': True, + }, + } + ) + + # Strip empty text content blocks from multimodal messages + # to prevent errors from providers like Gemini and Claude + form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', [])) + + # Merge any duplicate system messages into a single message at position 0 + # to prevent template parsing errors with strict chat templates (e.g. Qwen) + form_data['messages'] = merge_system_messages(form_data.get('messages', [])) + + return form_data, metadata, events + + +async def get_event_emitter_and_caller(metadata): + event_emitter = None + event_caller = None + + # event_emitter only needs user_id + chat_id + message_id. + # It broadcasts to user:{user_id} room AND persists to DB, + # so it works for backend-initiated calls (automations, API). + if metadata.get('chat_id') and metadata.get('message_id'): + event_emitter = await get_event_emitter(metadata) + + # event_caller needs session_id โ€” it calls back to a specific + # websocket session (used by direct tools, pyodide code interpreter). + if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): + event_caller = await get_event_call(metadata) + + return event_emitter, event_caller + + +async def build_chat_response_context(request, form_data, user, model, metadata, tasks, events): + event_emitter, event_caller = await get_event_emitter_and_caller(metadata) + return { + 'request': request, + 'form_data': form_data, + 'user': user, + 'model': model, + 'metadata': metadata, + 'tasks': tasks, + 'events': events, + 'event_emitter': event_emitter, + 'event_caller': event_caller, + } + + +def get_response_data(response): + if isinstance(response, list) and len(response) == 1: + # If the response is a single-item list, unwrap it #17213 + response = response[0] + + if isinstance(response, JSONResponse): + if isinstance(response.body, bytes): + try: + response_data = json.loads(response.body.decode('utf-8', 'replace')) + except json.JSONDecodeError: + response_data = {'error': {'detail': 'Invalid JSON response'}} + else: + response_data = response + elif isinstance(response, dict): + response_data = response + else: + response_data = None + + return response, response_data + + +def merge_events_into_response(response_data, events): + if events and isinstance(events, list): + extra_response = {} + for event in events: + if isinstance(event, dict): + extra_response.update(event) + else: + extra_response[event] = True + + return { + **extra_response, + **response_data, + } + return response_data + + +def build_response_object(response, response_data): + if isinstance(response, dict): + return response_data + if isinstance(response, JSONResponse): + return JSONResponse( + content=response_data, + headers=response.headers, + status_code=response.status_code, + ) + return response + + +async def get_system_oauth_token(request, user): + """Get the system OAuth token for a user. + + Primary path: use the oauth_session_id cookie (browser requests). + Fallback: look up the user's most recent OAuth session from the DB + (covers automations, API calls, and other cookie-less contexts). + """ + oauth_token = None + try: + oauth_session_id = request.cookies.get('oauth_session_id', None) + if oauth_session_id: + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + oauth_session_id, + ) + + # Fallback: no cookie (automation, API key, etc.) โ€” use most recent session + if oauth_token is None: + from open_webui.models.oauth_sessions import OAuthSessions + + sessions = await OAuthSessions.get_sessions_by_user_id(user.id) + # Filter out MCP-provider sessions โ€” their token refresh is handled + # separately by oauth_client_manager. Passing them to the SSO + # oauth_manager causes a failed refresh and session deletion (#24618). + sessions = [s for s in sessions if not (s.provider or '').startswith('mcp:')] + if sessions: + best = max(sessions, key=lambda s: s.updated_at) + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + best.id, + ) + except Exception as e: + log.error(f'Error getting OAuth token: {e}') + return oauth_token + + +async def background_tasks_handler(ctx): + request = ctx['request'] + form_data = ctx['form_data'] + user = ctx['user'] + metadata = ctx['metadata'] + tasks = ctx['tasks'] + event_emitter = ctx['event_emitter'] + + message = None + messages = [] + + if ( + 'chat_id' in metadata + and not metadata.get('chat_id', '').startswith('local:') + and not metadata.get('chat_id', '').startswith('channel:') + ): + messages_map = await Chats.get_messages_map_by_chat_id(metadata['chat_id']) + if not messages_map: + # Chat was deleted while the response was streaming โ€” skip background tasks + return + message = messages_map.get(metadata['message_id']) + + message_list = get_message_list(messages_map, metadata['message_id']) + + # Remove details tags and files from the messages. + # as get_message_list creates a new list, it does not affect + # the original messages outside of this handler + + messages = [] + for message in message_list: + content = message.get('content', '') + if isinstance(content, list): + for item in content: + if item.get('type') == 'text': + content = item['text'] + break + + if isinstance(content, str): + content = re.sub( + r']*>.*?<\/details>|!\[.*?\]\(.*?\)', + '', + content, + flags=re.S | re.I, + ).strip() + + messages.append( + { + **message, + 'role': message.get('role', 'assistant'), # Safe fallback for missing role + 'content': content, + } + ) + else: + # Local temp chat, get the model and message from the form_data + message = get_last_user_message_item(form_data.get('messages', [])) + messages = form_data.get('messages', []) + if message: + message['model'] = form_data.get('model') + + if message and 'model' in message: + if tasks and messages: + if TASKS.FOLLOW_UP_GENERATION in tasks and tasks[TASKS.FOLLOW_UP_GENERATION]: + res = await generate_follow_ups( + request, + { + 'model': message['model'], + 'messages': messages, + 'message_id': metadata['message_id'], + 'chat_id': metadata['chat_id'], + }, + user, + ) + + if res and isinstance(res, dict): + if len(res.get('choices', [])) == 1: + response_message = res.get('choices', [])[0].get('message', {}) + + follow_ups_string = response_message.get('content') or response_message.get( + 'reasoning_content', '' + ) + else: + follow_ups_string = '' + + follow_ups_string = follow_ups_string[ + follow_ups_string.find('{') : follow_ups_string.rfind('}') + 1 + ] + + try: + follow_ups = json.loads(follow_ups_string).get('follow_ups', []) + await event_emitter( + { + 'type': 'chat:message:follow_ups', + 'data': { + 'follow_ups': follow_ups, + }, + } + ) + + if not metadata.get('chat_id', '').startswith('local:') and not metadata.get( + 'chat_id', '' + ).startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'followUps': follow_ups, + }, + ) + + except Exception as e: + pass + + if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith( + 'channel:' + ): # Only update titles and tags for non-temp chats + if TASKS.TITLE_GENERATION in tasks: + user_message = get_last_user_message(messages) + if user_message and len(user_message) > 100: + user_message = user_message[:100] + '...' + + title = None + if tasks[TASKS.TITLE_GENERATION]: + res = await generate_title( + request, + { + 'model': message['model'], + 'messages': messages, + 'chat_id': metadata['chat_id'], + }, + user, + ) + + if res and isinstance(res, dict): + if len(res.get('choices', [])) == 1: + response_message = res.get('choices', [])[0].get('message', {}) + + title_string = ( + response_message.get('content') + or response_message.get( + 'reasoning_content', + ) + or message.get('content', user_message) + ) + else: + title_string = '' + + title_string = title_string[title_string.find('{') : title_string.rfind('}') + 1] + + try: + title = json.loads(title_string).get('title', user_message) + except Exception as e: + title = '' + + if not title: + title = messages[0].get('content', user_message) + + await Chats.update_chat_title_by_id(metadata['chat_id'], title) + + await event_emitter( + { + 'type': 'chat:title', + 'data': title, + } + ) + + if title == None and len(messages) == 2 and (not messages_map or len(messages_map) <= 2): + title = messages[0].get('content', user_message) + + await Chats.update_chat_title_by_id(metadata['chat_id'], title) + + await event_emitter( + { + 'type': 'chat:title', + 'data': message.get('content', user_message), + } + ) + + if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]: + res = await generate_chat_tags( + request, + { + 'model': message['model'], + 'messages': messages, + 'chat_id': metadata['chat_id'], + }, + user, + ) + + if res and isinstance(res, dict): + if len(res.get('choices', [])) == 1: + response_message = res.get('choices', [])[0].get('message', {}) + + tags_string = response_message.get('content') or response_message.get( + 'reasoning_content', '' + ) + else: + tags_string = '' + + tags_string = tags_string[tags_string.find('{') : tags_string.rfind('}') + 1] + + try: + tags = json.loads(tags_string).get('tags', []) + await Chats.update_chat_tags_by_id(metadata['chat_id'], tags, user) + + await event_emitter( + { + 'type': 'chat:tags', + 'data': tags, + } + ) + except Exception as e: + pass + + +async def outlet_filter_handler(ctx): + """Run outlet filters inline after chat completion. + + Replaces the separate POST /api/chat/completed round-trip. + Persists outlet-modified content to DB and emits a chat:outlet event + so the frontend can sync its in-memory state. + + For temp chats (local: prefix), messages are built from form_data + plus the assistant response message stored in ctx['assistant_message'], + since temp chats have no DB-persisted history. + """ + request = ctx['request'] + user = ctx['user'] + model = ctx['model'] + metadata = ctx['metadata'] + event_emitter = ctx.get('event_emitter') + event_caller = ctx.get('event_caller') + + chat_id = metadata.get('chat_id', '') + message_id = metadata.get('message_id') + + if not chat_id or not message_id: + return + + is_temp_chat = chat_id.startswith('local:') or chat_id.startswith('channel:') + + try: + messages_map = None + + if is_temp_chat: + # Temp chats have no DB record โ€” build message list from + # the in-memory form_data plus the assistant response. + form_messages = ctx.get('form_data', {}).get('messages', []) + assistant_message = ctx.get('assistant_message', {}) + + message_list = [ + { + 'role': m.get('role'), + 'content': m.get('content', ''), + } + for m in form_messages + ] + + # Append the full assistant message (content, output, usage, etc.) + if assistant_message: + message_list.append( + { + 'id': message_id, + 'role': 'assistant', + **assistant_message, + } + ) + else: + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) + if not messages_map: + return + + message_list = get_message_list(messages_map, message_id) + if not message_list: + return + + model_id = model.get('id') if isinstance(model, dict) else model + + outlet_data = { + 'model': model_id, + 'messages': [ + { + 'id': m.get('id'), + 'role': m.get('role'), + 'content': m.get('content', ''), + 'info': m.get('info'), + 'timestamp': m.get('timestamp'), + **({'output': m['output']} if m.get('output') else {}), + **({'usage': m['usage']} if m.get('usage') else {}), + **({'sources': m['sources']} if m.get('sources') else {}), + } + for m in message_list + ], + 'filter_ids': metadata.get('filter_ids', []), + 'chat_id': chat_id, + 'session_id': metadata.get('session_id'), + 'id': message_id, + } + + # Pipeline outlet filters + models = request.app.state.MODELS + try: + outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models) + except Exception as e: + log.debug(f'Pipeline outlet filter error: {e}') + + # Function outlet filters + extra_params = { + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__request__': request, + '__model__': model, + } + + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) + + outlet_result, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='outlet', + form_data=outlet_data, + extra_params=extra_params, + ) + + # Persist outlet-modified content and notify frontend + # (skip DB persistence for temp chats โ€” they have no DB record) + if outlet_result and outlet_result.get('messages'): + if not is_temp_chat and messages_map: + for message in outlet_result['messages']: + outlet_message_id = message.get('id') + if outlet_message_id and outlet_message_id in messages_map: + original_message = messages_map[outlet_message_id] + content_changed = original_message.get('content') != message.get('content') + output_changed = message.get('output') and message.get('output') != original_message.get( + 'output' + ) + if content_changed or output_changed: + # If output was modified, re-derive content from it + new_content = message.get('content', original_message.get('content', '')) + if output_changed: + new_content = serialize_output(message['output']) + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + outlet_message_id, + { + 'content': new_content, + 'originalContent': original_message.get('content'), + **({'output': message['output']} if output_changed else {}), + }, + ) + + if event_emitter: + await event_emitter( + { + 'type': 'chat:outlet', + 'data': {'messages': outlet_result['messages']}, + } + ) + except Exception as e: + log.debug(f'Error running outlet filters: {e}') + + +async def non_streaming_chat_response_handler(response, ctx): + request = ctx['request'] + + user = ctx['user'] + metadata = ctx['metadata'] + events = ctx['events'] + + event_emitter = ctx['event_emitter'] + + response, response_data = get_response_data(response) + if response_data is None: + return response + + if event_emitter: + try: + if 'error' in response_data: + error = response_data.get('error') + + if isinstance(error, dict): + error = error.get('detail', error) + else: + error = str(error) + + log.error('Provider returned error (non-streaming): %s', error) + + if not metadata.get('chat_id', '').startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) + if isinstance(error, str) or isinstance(error, dict): + await event_emitter( + { + 'type': 'chat:message:error', + 'data': {'error': {'content': error}}, + } + ) + + if 'selected_model_id' in response_data and not metadata.get('chat_id', '').startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'selectedModelId': response_data['selected_model_id'], + }, + ) + + choices = response_data.get('choices', []) + if choices and choices[0].get('message', {}).get('content'): + content = response_data['choices'][0]['message']['content'] + + if content: + await event_emitter( + { + 'type': 'chat:completion', + 'data': response_data, + } + ) + + title = ( + await Chats.get_chat_title_by_id(metadata['chat_id']) + if not metadata.get('chat_id', '').startswith('channel:') + else '' + ) + + # Use output from backend if provided (OR-compliant backends), + # otherwise generate from response content + response_output = response_data.get('output') + if not response_output: + response_output = [ + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'completed', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': content}], + } + ] + + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'done': True, + 'content': content, + 'output': response_output, + 'title': title, + }, + } + ) + + # Save message in the database + usage = normalize_usage(response_data.get('usage', {}) or {}) + + if not metadata.get('chat_id', '').startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'role': 'assistant', + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), + }, + ) + + # Send a webhook notification if the user is not active + if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): + webhook_url = await Users.get_user_webhook_url_by_id(user.id) + if webhook_url: + await post_webhook( + request.app.state.WEBUI_NAME, + webhook_url, + f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + { + 'action': 'chat', + 'message': content, + 'title': title, + 'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + }, + ) + + ctx['assistant_message'] = { + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), + } + await outlet_filter_handler(ctx) + await background_tasks_handler(ctx) + + response = build_response_object(response, merge_events_into_response(response_data, events)) + except Exception as e: + log.debug(f'Error occurred while processing request: {e}') + pass + + return response + + if isinstance(response, dict): + response = merge_events_into_response(response_data, events) + + return response + + +async def streaming_chat_response_handler(response, ctx): + request = ctx['request'] + + form_data = ctx['form_data'] + + user = ctx['user'] + model = ctx['model'] + + metadata = ctx['metadata'] + events = ctx['events'] + + event_emitter = ctx['event_emitter'] + event_caller = ctx['event_caller'] + + extra_params = { + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__oauth_token__': await get_system_oauth_token(request, user), + '__request__': request, + '__model__': model, + } + + filter_functions = [ + await Functions.get_function_by_id(filter_id) + for filter_id in await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + ] + + # Standard streaming response handler + # event_caller is optional โ€” only needed for direct (client-side) tools + # and pyodide code interpreter. Server-side tools work without it. + if event_emitter: + task_id = str(uuid4()) # Create a unique task ID. + model_id = form_data.get('model', '') + + # Handle as a background task + async def response_handler(response, events): + def tag_output_handler(content_type, tags, output): + """ + Detect special tags (reasoning, solution, code_interpreter) in streaming + content and create corresponding OR-aligned output items directly. + Operates on output items instead of content_blocks. + + Uses the text from the output items themselves for tag detection, + eliminating state divergence between accumulated content and items. + """ + end_flag = False + + def extract_attributes(tag_content): + """Extract attributes from a tag if they exist.""" + attributes = {} + if not tag_content: + return attributes + matches = re.findall(r'(\w+)\s*=\s*"([^"]+)"', tag_content) + for key, value in matches: + attributes[key] = value + return attributes + + def get_last_text(out): + """Get text from last message item, or empty string.""" + if out and out[-1].get('type') == 'message': + parts = out[-1].get('content', []) + if parts and parts[-1].get('type') == 'output_text': + return parts[-1].get('text', '') + return '' + + def set_last_text(out, text): + """Set text on last message item's output_text.""" + if out and out[-1].get('type') == 'message': + parts = out[-1].get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] = text + + # Map content_type to output item type + output_type_map = { + 'reasoning': 'reasoning', + 'solution': 'message', # solution tags just produce text + 'code_interpreter': 'open_webui:code_interpreter', + } + output_item_type = output_type_map.get(content_type, content_type) + + last_type = output[-1].get('type', '') if output else '' + + if last_type == 'message': + # Use the output item's own text for tag detection + item_text = get_last_text(output) + for start_tag, end_tag in tags: + start_tag_pattern = rf'{re.escape(start_tag)}' + if start_tag.startswith('<') and start_tag.endswith('>'): + start_tag_pattern = rf'<{re.escape(start_tag[1:-1])}(\s.*?)?>' + + match = re.search(start_tag_pattern, item_text) + if match: + try: + attr_content = match.group(1) if match.group(1) else '' + except Exception: + attr_content = '' + + attributes = extract_attributes(attr_content) + + before_tag = item_text[: match.start()] + after_tag = item_text[match.end() :] + + # Keep only text before the tag in the message + set_last_text(output, before_tag) + + if not before_tag.strip(): + # Remove empty message item + if output and output[-1].get('type') == 'message': + output.pop() + + # Append the new output item + if output_item_type == 'reasoning': + output.append( + { + 'type': 'reasoning', + 'id': output_id('r'), + 'status': 'in_progress', + 'start_tag': start_tag, + 'end_tag': end_tag, + 'attributes': attributes, + 'content': [], + 'summary': None, + 'started_at': time.time(), + } + ) + elif output_item_type == 'open_webui:code_interpreter': + output.append( + { + 'type': 'open_webui:code_interpreter', + 'id': output_id('ci'), + 'status': 'in_progress', + 'start_tag': start_tag, + 'end_tag': end_tag, + 'attributes': attributes, + 'lang': attributes.get('lang', 'python'), + 'code': '', + 'output': None, + 'started_at': time.time(), + } + ) + else: + # solution or other text-producing tag + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + '_tag_type': content_type, + 'start_tag': start_tag, + 'end_tag': end_tag, + 'attributes': attributes, + 'started_at': time.time(), + } + ) + + if after_tag: + # Set the after_tag content on the new item + if output_item_type == 'reasoning': + output[-1]['content'] = [{'type': 'output_text', 'text': after_tag}] + elif output_item_type == 'open_webui:code_interpreter': + output[-1]['code'] = after_tag + else: + set_last_text(output, after_tag) + + _, recursive_end = tag_output_handler(content_type, tags, output) + if recursive_end: + end_flag = True + + break + + elif ( + (last_type == 'reasoning' and content_type == 'reasoning') + or (last_type == 'open_webui:code_interpreter' and content_type == 'code_interpreter') + or (last_type == 'message' and output[-1].get('_tag_type') == content_type) + ): + item = output[-1] + start_tag = item.get('start_tag', '') + end_tag = item.get('end_tag', '') + + end_tag_pattern = rf'{re.escape(end_tag)}' + + # Get the block content from the item itself + if last_type == 'reasoning': + parts = item.get('content', []) + block_content = '' + if parts and parts[-1].get('type') == 'output_text': + block_content = parts[-1].get('text', '') + elif last_type == 'open_webui:code_interpreter': + block_content = item.get('code', '') + else: + block_content = get_last_text(output) + + if re.search(end_tag_pattern, block_content): + end_flag = True + + # Strip start and end tags from content + start_tag_pattern = rf'{re.escape(start_tag)}' + if start_tag.startswith('<') and start_tag.endswith('>'): + start_tag_pattern = rf'<{re.escape(start_tag[1:-1])}(\s.*?)?>' + block_content = re.sub(start_tag_pattern, '', block_content).strip() + + end_tag_regex = re.compile(end_tag_pattern, re.DOTALL) + split_content = end_tag_regex.split(block_content, maxsplit=1) + + block_content = split_content[0].strip() if split_content else '' + leftover_content = split_content[1].strip() if len(split_content) > 1 else '' + + if block_content: + # Update the item with final content + if last_type == 'reasoning': + item['content'] = [{'type': 'output_text', 'text': block_content}] + item['ended_at'] = time.time() + item['duration'] = int(item['ended_at'] - item['started_at']) + item['status'] = 'completed' + elif last_type == 'open_webui:code_interpreter': + item['code'] = block_content + item['ended_at'] = time.time() + item['duration'] = int(item['ended_at'] - item['started_at']) + else: + set_last_text(output, block_content) + item['ended_at'] = time.time() + + # Reset by appending a new message item for leftover + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ + { + 'type': 'output_text', + 'text': leftover_content, + } + ], + } + ) + else: + # Remove the block if content is empty + output.pop() + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ + { + 'type': 'output_text', + 'text': leftover_content, + } + ], + } + ) + + return output, end_flag + + message = await Chats.get_message_by_id_and_message_id(metadata['chat_id'], metadata['message_id']) + + tool_calls = [] + + last_assistant_message = None + try: + if form_data['messages'][-1]['role'] == 'assistant': + last_assistant_message = get_last_assistant_message(form_data['messages']) + except Exception as e: + pass + + content = ( + message.get('content', '') if message else last_assistant_message if last_assistant_message else '' + ) + + # Initialize output: use existing from message if continuing, else create new + existing_output = message.get('output') if message else None + if existing_output: + output = existing_output + else: + # Only create an initial message item if there is content to initialize with + if content: + output = [ + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': content}], + } + ] + else: + output = [] + + usage = None + prior_output = [] + last_response_id = None + + def full_output(): + return prior_output + output if prior_output else output + + reasoning_tags_param = metadata.get('params', {}).get('reasoning_tags') + DETECT_REASONING_TAGS = reasoning_tags_param is not False + + # Mirror the five gates from utils/tools.py get_builtin_tools so the + # legacy XML-tag path enforces the same authz as native FC. + features = metadata.get('features', {}) or {} + model_capabilities = model.get('info', {}).get('meta', {}).get('capabilities') or {} + builtin_tools_meta = model.get('info', {}).get('meta', {}).get('builtinTools', {}) + DETECT_CODE_INTERPRETER = ( + bool(features.get('code_interpreter')) + and builtin_tools_meta.get('code_interpreter', True) + and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True) + and model_capabilities.get('code_interpreter', True) + and ( + getattr(user, 'role', None) == 'admin' + or await has_permission( + getattr(user, 'id', ''), + 'features.code_interpreter', + request.app.state.config.USER_PERMISSIONS, + ) + ) + ) + + reasoning_tags = [] + if DETECT_REASONING_TAGS: + if isinstance(reasoning_tags_param, list) and len(reasoning_tags_param) == 2: + reasoning_tags = [(reasoning_tags_param[0], reasoning_tags_param[1])] + else: + reasoning_tags = DEFAULT_REASONING_TAGS + + try: + for event in events: + await event_emitter( + { + 'type': 'chat:completion', + 'data': event, + } + ) + + # Save message in the database + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + **event, + }, + ) + + async def stream_body_handler(response, form_data): + nonlocal content + nonlocal usage + nonlocal output + nonlocal prior_output + nonlocal last_response_id + + response_tool_calls = [] + + delta_count = 0 + delta_chunk_size = max( + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, + int(metadata.get('params', {}).get('stream_delta_chunk_size') or 1), + ) + last_delta_data = None + + async def flush_pending_delta_data(threshold: int = 0): + nonlocal delta_count + nonlocal last_delta_data + + if delta_count >= threshold and last_delta_data: + await event_emitter( + { + 'type': 'chat:completion', + 'data': last_delta_data, + } + ) + delta_count = 0 + last_delta_data = None + + async for line in response.body_iterator: + line = line.decode('utf-8', 'replace') if isinstance(line, bytes) else line + data = line + + # Skip empty lines + if not data.strip(): + continue + + # "data:" is the prefix for each event + if not data.startswith('data:'): + continue + + # Remove the prefix + data = data[len('data:') :].strip() + + try: + data = json.loads(data) + + data, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='stream', + form_data=data, + extra_params={'__body__': form_data, **extra_params}, + ) + + if data: + if 'event' in data and not getattr(request.state, 'direct', False): + await event_emitter(data.get('event', {})) + + if 'selected_model_id' in data: + model_id = data['selected_model_id'] + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'selectedModelId': model_id, + }, + ) + await event_emitter( + { + 'type': 'chat:completion', + 'data': data, + } + ) + # Check for Responses API events (type field starts with "response.") + elif data.get('type', '').startswith('response.'): + output, response_metadata = handle_responses_streaming_event(data, output) + + # Emit citation sources from finalized output items + # (mirrors Chat Completions annotation handling at delta level) + if data.get('type') == 'response.output_item.done': + item = data.get('item', {}) + if item.get('type') == 'message': + for part in item.get('content', []): + for annotation in part.get('annotations', []): + if annotation.get('type') == 'url_citation': + # Handle both flat (Responses API) and nested (Chat Completions) formats + url_citation = annotation.get('url_citation', annotation) + + url = url_citation.get('url', '') + title = url_citation.get('title', url) + + if url: + await event_emitter( + { + 'type': 'source', + 'data': { + 'source': { + 'name': title, + 'url': url, + }, + 'document': [title], + 'metadata': [ + { + 'source': url, + 'name': title, + } + ], + }, + } + ) + + processed_data = { + 'output': full_output(), + 'content': serialize_output(full_output()), + } + + # print(data) + # print(processed_data) + + # Merge any metadata (usage, etc.) + # Strip 'done' โ€” response.completed emits + # it but we may still need to execute tool + # calls. The outer middleware manages the + # actual completion signal. + if response_metadata: + if ENABLE_RESPONSES_API_STATEFUL: + response_id = response_metadata.pop('response_id', None) + if response_id: + last_response_id = response_id + + # Normalize and capture usage for DB persistence + if response_metadata.get('usage'): + response_metadata['usage'] = normalize_usage(response_metadata['usage']) + usage = response_metadata['usage'] + + processed_data.update(response_metadata) + processed_data.pop('done', None) + + await event_emitter( + { + 'type': 'chat:completion', + 'data': processed_data, + } + ) + continue + else: + choices = data.get('choices', []) + + # Normalize usage data to standard format + raw_usage = data.get('usage', {}) or {} + raw_usage.update(data.get('timings', {})) # llama.cpp + if raw_usage: + usage = normalize_usage(raw_usage) + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'usage': usage, + }, + } + ) + + if not choices: + error = data.get('error', {}) + if error: + log.error('Provider returned error (streaming): %s', error) + try: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) + except Exception: + pass + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'error': error, + }, + } + ) + continue + + delta = choices[0].get('delta', {}) + + # Handle delta annotations + annotations = delta.get('annotations') + if annotations: + for annotation in annotations: + if ( + annotation.get('type') == 'url_citation' + and 'url_citation' in annotation + ): + url_citation = annotation['url_citation'] + + url = url_citation.get('url', '') + title = url_citation.get('title', url) + + await event_emitter( + { + 'type': 'source', + 'data': { + 'source': { + 'name': title, + 'url': url, + }, + 'document': [title], + 'metadata': [ + { + 'source': url, + 'name': title, + } + ], + }, + } + ) + + delta_tool_calls = delta.get('tool_calls', None) + if delta_tool_calls: + for delta_tool_call in delta_tool_calls: + tool_call_index = delta_tool_call.get('index') + + if tool_call_index is not None: + # Check if the tool call already exists + current_response_tool_call = None + for response_tool_call in response_tool_calls: + if response_tool_call.get('index') == tool_call_index: + current_response_tool_call = response_tool_call + break + + if current_response_tool_call is None: + # Add the new tool call + delta_tool_call.setdefault('function', {}) + delta_tool_call['function'].setdefault('name', '') + delta_tool_call['function'].setdefault('arguments', '') + response_tool_calls.append(delta_tool_call) + else: + # Update the existing tool call + delta_name = delta_tool_call.get('function', {}).get('name') + delta_arguments = delta_tool_call.get('function', {}).get( + 'arguments' + ) + + if delta_name: + current_response_tool_call['function']['name'] = delta_name + + if delta_arguments: + current_response_tool_call['function']['arguments'] += ( + delta_arguments + ) + + # Emit pending tool calls in real-time + if response_tool_calls: + # Flush any pending text first + await flush_pending_delta_data() + + # Build pending function_call output items for display + pending_fc_items = [] + for tc in response_tool_calls: + call_id = tc.get('id', '') + func = tc.get('function', {}) + pending_fc_items.append( + { + 'type': 'function_call', + 'id': call_id or output_id('fc'), + 'call_id': call_id, + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + 'status': 'in_progress', + } + ) + + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(full_output() + pending_fc_items), + }, + } + ) + + image_urls = await get_image_urls(delta.get('images', []), request, metadata, user) + if image_urls: + image_file_list = [{'type': 'image', 'url': url} for url in image_urls] + message_files = await Chats.add_message_files_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + image_file_list, + ) + if message_files is None: + message_files = image_file_list + + await event_emitter( + { + 'type': 'files', + 'data': {'files': message_files}, + } + ) + + value = delta.get('content') + + reasoning_content = ( + delta.get('reasoning_content') + or delta.get('reasoning') + or delta.get('thinking') + ) + if reasoning_content: + if not output or output[-1].get('type') != 'reasoning': + reasoning_item = { + 'type': 'reasoning', + 'id': output_id('r'), + 'status': 'in_progress', + 'start_tag': '', + 'end_tag': '', + 'attributes': {'type': 'reasoning_content'}, + 'content': [], + 'summary': None, + 'started_at': time.time(), + } + output.append(reasoning_item) + else: + reasoning_item = output[-1] + + # Append to reasoning content + parts = reasoning_item.get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] += reasoning_content + else: + reasoning_item['content'] = [ + { + 'type': 'output_text', + 'text': reasoning_content, + } + ] + + data = {'content': serialize_output(full_output())} + + if value: + if ( + output + and output[-1].get('type') == 'reasoning' + and output[-1].get('attributes', {}).get('type') == 'reasoning_content' + ): + reasoning_item = output[-1] + reasoning_item['ended_at'] = time.time() + reasoning_item['duration'] = int( + reasoning_item['ended_at'] - reasoning_item['started_at'] + ) + reasoning_item['status'] = 'completed' + + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ + { + 'type': 'output_text', + 'text': '', + } + ], + } + ) + + if ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION: + value = await convert_markdown_base64_images( + request, + value, + { + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), + }, + user, + ) + + content = f'{content}{value}' + + # Check if we're inside a tag-based block + # (reasoning, code_interpreter, or solution). + # If so, append to the existing in-progress + # item instead of creating a new message โ€” + # otherwise tag_output_handler re-detects the + # start tag on every chunk and fragments the + # output. + last_item = output[-1] if output else None + last_item_type = last_item.get('type', '') if last_item else '' + inside_tag_block = ( + last_item is not None + and last_item.get('status') == 'in_progress' + and last_item.get('attributes', {}).get('type') != 'reasoning_content' + and ( + last_item_type == 'reasoning' + or last_item_type == 'open_webui:code_interpreter' + or ( + last_item_type == 'message' + and last_item.get('_tag_type') is not None + ) + ) + ) + + if inside_tag_block: + # Append to the existing tag-based item + if last_item_type == 'open_webui:code_interpreter': + last_item['code'] = last_item.get('code', '') + value + elif last_item_type == 'reasoning': + parts = last_item.get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] += value + else: + last_item['content'] = [ + { + 'type': 'output_text', + 'text': value, + } + ] + else: + # solution or other _tag_type message + msg_parts = last_item.get('content', []) + if msg_parts and msg_parts[-1].get('type') == 'output_text': + msg_parts[-1]['text'] += value + else: + last_item['content'] = [ + { + 'type': 'output_text', + 'text': value, + } + ] + else: + if not output or output[-1].get('type') != 'message': + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ + { + 'type': 'output_text', + 'text': '', + } + ], + } + ) + + # Append value to last message item's text + msg_parts = output[-1].get('content', []) + if msg_parts and msg_parts[-1].get('type') == 'output_text': + msg_parts[-1]['text'] += value + else: + output[-1]['content'] = [ + { + 'type': 'output_text', + 'text': value, + } + ] + + if DETECT_REASONING_TAGS: + output, _ = tag_output_handler( + 'reasoning', + reasoning_tags, + output, + ) + + output, _ = tag_output_handler( + 'solution', + DEFAULT_SOLUTION_TAGS, + output, + ) + + if DETECT_CODE_INTERPRETER: + output, end = tag_output_handler( + 'code_interpreter', + DEFAULT_CODE_INTERPRETER_TAGS, + output, + ) + + if end: + break + + if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith( + 'channel:' + ): + # Save message in the database + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'content': serialize_output(full_output()), + 'output': full_output(), + }, + ) + else: + data = { + 'content': serialize_output(full_output()), + } + + if delta: + delta_count += 1 + last_delta_data = data + if delta_count >= delta_chunk_size: + await flush_pending_delta_data(delta_chunk_size) + else: + await event_emitter( + { + 'type': 'chat:completion', + 'data': data, + } + ) + except (asyncio.CancelledError, KeyboardInterrupt): + raise + except Exception as e: + done = 'data: [DONE]' in line + if done: + pass + else: + log.debug(f'Error: {e}') + continue + await flush_pending_delta_data() + + if output: + # Clean up the last message item + if output[-1].get('type') == 'message': + parts = output[-1].get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] = parts[-1]['text'].strip() + + if not parts[-1]['text']: + output.pop() + + if not output: + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + } + ) + + if output[-1].get('type') == 'reasoning': + reasoning_item = output[-1] + if reasoning_item.get('ended_at') is None: + reasoning_item['ended_at'] = time.time() + reasoning_item['duration'] = int( + reasoning_item['ended_at'] - reasoning_item['started_at'] + ) + reasoning_item['status'] = 'completed' + + if response_tool_calls: + tool_calls.append(_split_tool_calls(response_tool_calls)) + + # Responses API path: extract function_call items from output + if not response_tool_calls and output: + # Collect call_ids that already have results, + # including those from prior_output so we don't + # re-process tool calls from a previous turn. + handled_call_ids = { + item.get('call_id') + for item in (prior_output + output) + if item.get('type') == 'function_call_output' + } + responses_api_tool_calls = [] + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids: + arguments = item.get('arguments', '{}') + responses_api_tool_calls.append( + { + 'id': item.get('call_id', ''), + 'index': len(responses_api_tool_calls), + 'function': { + 'name': item.get('name', ''), + 'arguments': ( + arguments if isinstance(arguments, str) else json.dumps(arguments) + ), + }, + } + ) + if responses_api_tool_calls: + tool_calls.append(_split_tool_calls(responses_api_tool_calls)) + + try: + await stream_body_handler(response, form_data) + finally: + if response.background: + await response.background() + + tool_call_iterations = 0 + tool_call_sources = [] # Track citation sources from tool results + all_tool_call_sources = [] # Accumulated sources across all iterations + user_message = get_last_user_message(form_data['messages']) + + # Check if citations are enabled for this model + citations_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( + 'citations', True + ) + + # Use the pre-RAG system content captured before the + # initial file-source injection in process_chat_payload. + # This ensures restore truly undoes the RAG template. + original_system_content = metadata.get('system_prompt') + if original_system_content is None: + original_system_message = get_system_message(form_data['messages']) + original_system_content = ( + get_content_from_message(original_system_message) if original_system_message else None + ) + + while tool_calls and ( + CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is None + or tool_call_iterations < CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS + ): + tool_call_iterations += 1 + + response_tool_calls = tool_calls.pop(0) + + # Append function_call items for each tool call + # (Responses API already has them from streaming, so skip duplicates) + existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'} + for tc in response_tool_calls: + call_id = tc.get('id', '') + if call_id not in existing_call_ids: + func = tc.get('function', {}) + output.append( + { + 'type': 'function_call', + 'id': call_id or output_id('fc'), + 'call_id': call_id, + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + 'status': 'in_progress', + } + ) + + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(full_output()), + 'output': full_output(), + }, + } + ) + + tools = metadata.get('tools', {}) + + results = [] + + for tool_call in response_tool_calls: + tool_call_id = tool_call.get('id', '') + tool_function_name = tool_call.get('function', {}).get('name', '') + tool_args = tool_call.get('function', {}).get('arguments', '{}') + + tool_function_params = {} + if tool_args and tool_args.strip(): + try: + # json.loads cannot be used because some models do not produce valid JSON + tool_function_params = ast.literal_eval(tool_args) + except Exception as e: + log.debug(e) + # Fallback to JSON parsing + try: + tool_function_params = json.loads(tool_args) + except Exception as e: + log.error(f'Error parsing tool call arguments: {tool_args}') + results.append( + { + 'tool_call_id': tool_call_id, + 'content': f'Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.', + } + ) + continue + + # Ensure arguments are valid JSON for downstream LLM integrations + log.debug(f'Parsed args from {tool_args} to {tool_function_params}') + tool_call.setdefault('function', {})['arguments'] = json.dumps(tool_function_params) + + tool_result = None + tool = None + tool_type = None + direct_tool = False + + if tool_function_name in tools: + tool = tools[tool_function_name] + spec = tool.get('spec', {}) + + tool_type = tool.get('type', '') + direct_tool = tool.get('direct', False) + + try: + allowed_params = spec.get('parameters', {}).get('properties', {}).keys() + + tool_function_params = { + k: v for k, v in tool_function_params.items() if k in allowed_params + } + + if direct_tool: + tool_result = await event_caller( + { + 'type': 'execute:tool', + 'data': { + 'id': str(uuid4()), + 'name': tool_function_name, + 'params': tool_function_params, + 'server': tool.get('server', {}), + 'session_id': metadata.get('session_id', None), + }, + } + ) + + else: + tool_function = await get_updated_tool_function( + function=tool['callable'], + extra_params={ + '__messages__': form_data.get('messages', []), + '__files__': metadata.get('files', []), + }, + ) + + tool_result = await tool_function(**tool_function_params) + + except Exception as e: + tool_result = str(e) + else: + tool_result = f'Error: Tool "{tool_function_name}" not found.' + + tool_result, tool_result_files, tool_result_embeds = await process_tool_result( + request, + tool_function_name, + tool_result, + tool_type, + direct_tool, + metadata, + user, + ) + + await terminal_event_handler( + tool_function_name, + tool_function_params, + tool_result, + event_emitter, + ) + + # Extract citation sources from tool results + if ( + citations_enabled + and tool_function_name + in [ + 'search_web', + 'fetch_url', + 'view_file', + 'view_knowledge_file', + 'query_knowledge_files', + ] + and tool_result + ): + try: + citation_sources = get_citation_source_from_tool_result( + tool_name=tool_function_name, + tool_params=tool_function_params, + tool_result=tool_result, + tool_id=tool.get('tool_id', '') if tool else '', + ) + tool_call_sources.extend(citation_sources) + except Exception as e: + log.exception(f'Error extracting citation source: {e}') + + results.append( + { + 'tool_call_id': tool_call_id, + 'content': str(tool_result) if tool_result else '', + **({'files': tool_result_files} if tool_result_files else {}), + **({'embeds': tool_result_embeds} if tool_result_embeds else {}), + } + ) + + # Update function_call statuses and append function_call_output items + for tc in response_tool_calls: + call_id = tc.get('id', '') + # Mark function_call as completed + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') == call_id: + item['status'] = 'completed' + # Update arguments with parsed/sanitized version + item['arguments'] = tc.get('function', {}).get('arguments', '{}') + break + + for result in results: + output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + + # Separate image data URIs (for LLM via input_image) from + # other files (for frontend display via files attribute). + display_files = [] + for file_item in result.get('files', []): + if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): + # LLM-only: add as input_image part (invisible to serialize_output) + output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) + else: + # Frontend display (MCP images, audio, etc.) + display_files.append(file_item) + + output.append( + { + 'type': 'function_call_output', + 'id': output_id('fco'), + 'call_id': result.get('tool_call_id', ''), + 'output': output_parts, + 'status': 'completed', + **({'files': display_files} if display_files else {}), + **({'embeds': result.get('embeds')} if result.get('embeds') else {}), + } + ) + + # Append a new empty message item for the next response + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + } + ) + + # Emit citation sources to the frontend for display + if citations_enabled: + for source in tool_call_sources: + await event_emitter({'type': 'source', 'data': source}) + + # Apply tool source context to messages for the model. + # Restoring to pre-RAG original prevents duplicating + # the RAG template across file and tool sources. + all_tool_call_sources.extend(tool_call_sources) + if all_tool_call_sources and user_message: + # Restore pre-RAG message state before re-applying + # to prevent RAG template duplication. + original_user_message = metadata.get('user_prompt') or user_message + set_last_user_message_content( + original_user_message, + form_data['messages'], + ) + replace_system_message_content( + original_system_content or '', + form_data['messages'], + ) + + # Build context: file sources with content, + # tool sources as citation markers only. + source_ids = {} + source_context = get_source_context( + metadata.get('sources', []), source_ids + ) + get_source_context( + all_tool_call_sources, + source_ids, + include_content=False, + ) + source_context = source_context.strip() + if source_context: + rag_content = await rag_template( + request.app.state.config.RAG_TEMPLATE, + source_context, + user_message, + ) + if RAG_SYSTEM_CONTEXT: + form_data['messages'] = add_or_update_system_message( + rag_content, + form_data['messages'], + append=True, + ) + else: + form_data['messages'] = add_or_update_user_message( + rag_content, + form_data['messages'], + append=False, + ) + tool_call_sources.clear() + + # Strip input_image parts (large base64 data URIs) from the + # output sent to the frontend โ€” they're only for LLM consumption + # via convert_output_to_messages. + frontend_output = [] + for item in output: + if item.get('type') == 'function_call_output': + parts = item.get('output', []) + if any(p.get('type') == 'input_image' for p in parts): + item = {**item, 'output': [p for p in parts if p.get('type') != 'input_image']} + frontend_output.append(item) + + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(output), + 'output': frontend_output, + }, + } + ) + + try: + new_form_data = { + **form_data, + 'model': model_id, + 'stream': True, + 'metadata': metadata, + } + + if ENABLE_RESPONSES_API_STATEFUL and last_response_id: + system_message = get_system_message(form_data['messages']) + new_form_data['messages'] = ( + [system_message] if system_message else [] + ) + convert_output_to_messages( + output, raw=True, reasoning_format=get_reasoning_format(model) + ) + new_form_data['previous_response_id'] = last_response_id + else: + tool_messages = convert_output_to_messages( + output, raw=True, reasoning_format=get_reasoning_format(model) + ) + + # Chat Completions providers don't support multimodal + # tool messages. Extract images into a user message. + image_urls = [] + for message in tool_messages: + if message.get('role') == 'tool' and isinstance(message.get('content'), list): + text_parts = [] + for part in message['content']: + if part.get('type') == 'input_text': + text_parts.append(part.get('text', '')) + elif part.get('type') == 'input_image': + image_urls.append(part.get('image_url', '')) + message['content'] = ''.join(text_parts) + + new_form_data['messages'] = [ + *form_data['messages'], + *tool_messages, + ] + + if image_urls: + new_form_data['messages'].append( + { + 'role': 'user', + 'content': [ + { + 'type': 'text', + 'text': 'Here are the images from the tool results above. Please analyze them.', + }, + *[{'type': 'image_url', 'image_url': {'url': url}} for url in image_urls], + ], + } + ) + + res = await generate_chat_completion( + request, + new_form_data, + user, + bypass_system_prompt=True, + ) + + if isinstance(res, StreamingResponse): + # Save accumulated output and start fresh. + # Responses API output_index values are relative + # to the current response โ€” a clean output list + # keeps indices aligned. The display prefix + # ensures the UI shows tool history during + # streaming. + prior_output = list(output) + # Trim the trailing empty placeholder message + # so it doesn't persist as a ghost item once + # the new stream produces real content. + if ( + prior_output + and prior_output[-1].get('type') == 'message' + and prior_output[-1].get('status') == 'in_progress' + ): + msg_parts = prior_output[-1].get('content', []) + if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()): + prior_output.pop() + output = [] + await stream_body_handler(res, new_form_data) + output[:0] = prior_output + prior_output = [] + else: + break + except Exception as e: + log.debug(e) + break + + if ( + CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is not None + and tool_calls + and tool_call_iterations >= CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS + ): + log.warning('Tool-call iteration limit reached (%s)', CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS) + error_content = f'Tool-call limit reached ({CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS} iterations).' + if not metadata.get('chat_id', '').startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'error': {'content': error_content}}, + ) + await event_emitter( + { + 'type': 'chat:message:error', + 'data': {'error': {'content': error_content}}, + } + ) + + if DETECT_CODE_INTERPRETER: + MAX_RETRIES = 5 + retries = 0 + + while output and output[-1].get('type') == 'open_webui:code_interpreter' and retries < MAX_RETRIES: + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(output), + 'output': output, + }, + } + ) + + retries += 1 + log.debug(f'Attempt count: {retries}') + + ci_item = output[-1] + ci_output = '' + try: + if ci_item.get('attributes', {}).get('type') == 'code': + code = ci_item.get('code', '') + # Sanitize code (strips ANSI codes and markdown fences) + code = sanitize_code(code) + + if CODE_INTERPRETER_BLOCKED_MODULES: + blocking_code = textwrap.dedent(f""" + import builtins + + BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} + + _real_import = builtins.__import__ + async def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.split('.')[0] in BLOCKED_MODULES: + importer_name = globals.get('__name__') if globals else None + if importer_name == '__main__': + raise ImportError( + f"Direct import of module {{name}} is restricted." + ) + return _real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = restricted_import + """) + code = blocking_code + '\n' + code + + if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide': + ci_output = await event_caller( + { + 'type': 'execute:python', + 'data': { + 'id': str(uuid4()), + 'code': code, + 'session_id': metadata.get('session_id', None), + 'files': metadata.get('files', []), + }, + } + ) + elif request.app.state.config.CODE_INTERPRETER_ENGINE == 'jupyter': + ci_output = await execute_code_jupyter( + request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, + code, + ( + request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN + if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token' + else None + ), + ( + request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD + if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password' + else None + ), + request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, + ) + else: + ci_output = {'stdout': 'Code interpreter engine not configured.'} + + log.debug(f'Code interpreter output: {ci_output}') + + # Handle error responses from event_caller + # (e.g. session disconnected, timeout) + if isinstance(ci_output, dict) and ci_output.get('error'): + ci_output = {'stderr': ci_output['error']} + + if isinstance(ci_output, dict): + stdout = ci_output.get('stdout', '') + + if isinstance(stdout, str): + stdoutLines = stdout.split('\n') + for idx, line in enumerate(stdoutLines): + if re.match(r'data:image/\w+;base64', line): + image_url = await get_image_url_from_base64( + request, + line, + metadata, + user, + ) + if image_url: + stdoutLines[idx] = f'![Output Image]({image_url})' + + ci_output['stdout'] = '\n'.join(stdoutLines) + + result = ci_output.get('result', '') + + if isinstance(result, str): + resultLines = result.split('\n') + for idx, line in enumerate(resultLines): + if re.match(r'data:image/\w+;base64', line): + image_url = await get_image_url_from_base64( + request, + line, + metadata, + user, + ) + resultLines[idx] = f'![Output Image]({image_url})' + ci_output['result'] = '\n'.join(resultLines) + except Exception as e: + ci_output = str(e) + + ci_item['output'] = ci_output + ci_item['status'] = 'completed' + + output.append( + { + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + } + ) + + await event_emitter( + { + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(output), + 'output': output, + }, + } + ) + + try: + new_form_data = { + **form_data, + 'model': model_id, + 'stream': True, + 'metadata': metadata, + 'messages': [ + *form_data['messages'], + *convert_output_to_messages( + output, raw=True, reasoning_format=get_reasoning_format(model) + ), + ], + } + + res = await generate_chat_completion( + request, + new_form_data, + user, + bypass_system_prompt=True, + ) + + if isinstance(res, StreamingResponse): + await stream_body_handler(res, new_form_data) + else: + break + except Exception as e: + log.debug(e) + break + + # Mark all in-progress items as completed + for item in output: + if item.get('status') == 'in_progress': + item['status'] = 'completed' + + title = ( + await Chats.get_chat_title_by_id(metadata['chat_id']) + if not metadata.get('chat_id', '').startswith('channel:') + else '' + ) + data = { + 'done': True, + 'content': serialize_output(output), + 'output': output, + 'title': title, + **({'usage': usage} if usage else {}), + } + + if not metadata.get('chat_id', '').startswith('channel:'): + if not ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), + }, + ) + elif usage: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True, 'usage': usage}, + ) + else: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) + + # Send a webhook notification if the user is not active + if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): + webhook_url = await Users.get_user_webhook_url_by_id(user.id) + if webhook_url: + await post_webhook( + request.app.state.WEBUI_NAME, + webhook_url, + f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + { + 'action': 'chat', + 'message': content, + 'title': title, + 'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', + }, + ) + + await event_emitter( + { + 'type': 'chat:completion', + 'data': data, + } + ) + + ctx['assistant_message'] = { + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), + } + await outlet_filter_handler(ctx) + await background_tasks_handler(ctx) + except asyncio.CancelledError: + log.warning('Task was cancelled!') + + # Close the response body iterator to trigger cleanup + # in stream_wrapper's finally block and release the + # upstream connection. Without this, the async + # generator is orphaned and may spin in anyio internals. + if hasattr(response, 'body_iterator') and hasattr(response.body_iterator, 'aclose'): + try: + await asyncio.shield(response.body_iterator.aclose()) + except (asyncio.CancelledError, Exception): + pass + + async def save_cancelled_state(): + await event_emitter({'type': 'chat:tasks:cancel'}) + if not metadata.get('chat_id', '').startswith('channel:'): + if not ENABLE_REALTIME_CHAT_SAVE: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + }, + ) + else: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) + + try: + await asyncio.shield(save_cancelled_state()) + except (asyncio.CancelledError, Exception): + pass + raise # re-raise CancelledError for proper propagation + + if response.background is not None: + await response.background() + + return await response_handler(response, events) + + else: + # Fallback to the original response + async def stream_wrapper(original_generator, events): + def wrap_item(item): + return f'data: {item}\n\n' + + for event in events: + event, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='stream', + form_data=event, + extra_params=extra_params, + ) + + if event: + yield wrap_item(json.dumps(event)) + + async for data in original_generator: + data, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='stream', + form_data=data, + extra_params=extra_params, + ) + + if data: + yield data + + return StreamingResponse( + stream_wrapper(response.body_iterator, events), + headers=dict(response.headers), + background=response.background, + ) + + +async def process_chat_response(response, ctx): + # Non-streaming response + if not isinstance(response, StreamingResponse): + return await non_streaming_chat_response_handler(response, ctx) + + # Non standard response + if not any( + content_type in response.headers['Content-Type'] + for content_type in ['text/event-stream', 'application/x-ndjson'] + ): + return response + + # Streaming response + return await streaming_chat_response_handler(response, ctx) diff --git a/_verify_owui/misc.py b/_verify_owui/misc.py new file mode 100644 index 0000000..d5d8078 --- /dev/null +++ b/_verify_owui/misc.py @@ -0,0 +1,1095 @@ +from __future__ import annotations + +import collections.abc +import hashlib +import json +import logging +import re +import threading +import time +import uuid +from datetime import timedelta +from pathlib import Path +from typing import Callable, Optional, Sequence, Union + +import aiohttp +import mimeparse +from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE + +log = logging.getLogger(__name__) + + +def deep_update(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = deep_update(d.get(k, {}), v) + else: + d[k] = v + return d + + +def get_allow_block_lists(filter_list): + allow_list = [] + block_list = [] + + if filter_list: + for d in filter_list: + if d.startswith('!'): + # Domains starting with "!" โ†’ blocked + block_list.append(d[1:].strip()) + else: + # Domains starting without "!" โ†’ allowed + allow_list.append(d.strip()) + + return allow_list, block_list + + +def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool: + """ + Checks if a string is allowed based on the provided filter list. + :param string: The string or sequence of strings to check (e.g., domain or hostname). + :param filter_list: List of allowed/blocked strings. Strings starting with "!" are blocked. + :return: True if the string or sequence of strings is allowed, False otherwise. + """ + if not filter_list: + return True + + allow_list, block_list = get_allow_block_lists(filter_list) + strings = [string] if isinstance(string, str) else list(string) + + # If allow list is non-empty, require domain to match one of them + if allow_list: + if not any(s.endswith(allowed) for s in strings for allowed in allow_list): + return False + + # Block list always removes matches + if any(s.endswith(blocked) for s in strings for blocked in block_list): + return False + + return True + + +def get_message_list(messages_map, message_id): + """ + Reconstructs a list of messages in order up to the specified message_id. + + :param message_id: ID of the message to reconstruct the chain + :param messages: Message history dict containing all messages + :return: List of ordered messages starting from the root to the given message + """ + + # Handle case where messages is None + if not messages_map: + return [] # Return empty list instead of None to prevent iteration errors + + # Find the message by its id + current_message = messages_map.get(message_id) + + if not current_message: + return [] # Return empty list instead of None to prevent iteration errors + + # Reconstruct the chain by following the parentId links + message_list = [] + visited_message_ids = set() + + while current_message: + message_id = current_message.get('id') + if message_id in visited_message_ids: + # Cycle detected, break to prevent infinite loop + break + + if message_id is not None: + visited_message_ids.add(message_id) + + message_list.append(current_message) + parent_id = current_message.get('parentId') # Use .get() for safety + current_message = messages_map.get(parent_id) if parent_id else None + + message_list.reverse() + return message_list + + +def get_messages_content(messages: list[dict]) -> str: + return '\n'.join([f'{message["role"].upper()}: {get_content_from_message(message)}' for message in messages]) + + +def get_last_user_message_item(messages: list[dict]) -> dict | None: + for message in reversed(messages): + if message['role'] == 'user': + return message + return None + + +def get_content_from_message(message: dict) -> str | None: + if isinstance(message.get('content'), list): + for item in message['content']: + if item['type'] == 'text': + return item['text'] + else: + return message.get('content') + return None + + +def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: + """Drop unpaired tool_use / tool_result from a reconstructed conversation. + + Stored output can be incomplete โ€” a tool result may be missing (e.g. the + knowledge base was updated mid-chat, or the call was interrupted), or a + tool call may be missing while its result survived. Strict providers + (Anthropic, AWS Bedrock Converse) reject either direction of mismatch. + + Well-formed output is unaffected: every id pairs, so nothing is stripped. + """ + completed_tool_call_ids = { + message['tool_call_id'] for message in messages if message.get('role') == 'tool' and message.get('tool_call_id') + } + requested_tool_call_ids = { + tool_call['id'] + for message in messages + for tool_call in message.get('tool_calls') or () + if message.get('role') == 'assistant' and tool_call.get('id') + } + + reconciled_messages = [] + for message in messages: + role = message.get('role') + + # Orphan tool result โ€” no assistant ever claimed this call_id. + if role == 'tool' and message.get('tool_call_id') not in requested_tool_call_ids: + continue + + # Non-assistant or no tool_calls โ€” pass through unchanged. + if role != 'assistant' or not message.get('tool_calls'): + reconciled_messages.append(message) + continue + + # Keep only tool_calls whose id received a tool-role response. + valid_tool_calls = [ + tool_call for tool_call in message['tool_calls'] if tool_call.get('id') in completed_tool_call_ids + ] + + if valid_tool_calls: + reconciled_messages.append({**message, 'tool_calls': valid_tool_calls}) + continue + + # All tool_calls were orphans โ€” keep the message only if it + # carries meaningful text or reasoning content. + content = message.get('content', '') + has_meaningful_content = content.strip() if isinstance(content, str) else content + if has_meaningful_content or message.get('reasoning_content'): + reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'}) + + return reconciled_messages + + +def convert_output_to_messages( + output: list, + raw: bool = False, + reasoning_format: str | None = None, +) -> list[dict]: + """ + Convert OR-aligned output items to OpenAI Chat Completion-format messages. + + This reconstructs the full conversation from the stored Responses API-native + output items, including assistant messages with tool_calls arrays and tool + role messages. + + Args: + output: List of OR-aligned output items (Responses API format). + raw: If True, include code interpreter blocks for LLM re-processing + follow-ups. + reasoning_format: How to include reasoning blocks in the output: + - None: skip reasoning (default, safe for strict providers). + - ``'think_tags'``: wrap in ```` tags inside content + (for Ollama, which expects reasoning as tagged content). + - ``'reasoning_content'``: set as ``reasoning_content`` top-level field + (for llama.cpp, which routes it via the chat template). + """ + if not output or not isinstance(output, list): + return [] + + messages = [] + pending_tool_calls = [] + pending_content = [] + pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content' + + def flush_pending(): + nonlocal pending_content, pending_tool_calls, pending_reasoning + if not pending_content and not pending_tool_calls and not pending_reasoning: + return + + message = { + 'role': 'assistant', + 'content': '\n'.join(pending_content) if pending_content else '', + **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), + } + + if pending_reasoning: + message['reasoning_content'] = '\n'.join(pending_reasoning) + + messages.append(message) + pending_content = [] + pending_tool_calls = [] + pending_reasoning = [] + + for item in output: + item_type = item.get('type', '') + + if item_type == 'message': + # Extract text from output_text content parts + content_parts = item.get('content', []) + text = '' + for part in content_parts: + if part.get('type') == 'output_text': + text += part.get('text', '') + if text: + pending_content.append(text) + + elif item_type == 'function_call': + # Collect tool calls to batch into assistant message + arguments = item.get('arguments', '{}') + # Ensure arguments is always a JSON string + if not isinstance(arguments, str): + arguments = json.dumps(arguments) + pending_tool_calls.append( + { + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': { + 'name': item.get('name', ''), + 'arguments': arguments, + }, + } + ) + + elif item_type == 'function_call_output': + # Flush any pending content/tool_calls before adding tool result + flush_pending() + + # Extract text and images from output content parts + output_parts = item.get('output', []) + content = '' + image_urls = [] + for part in output_parts: + if part.get('type') == 'input_text': + output_text = part.get('text', '') + content += str(output_text) if not isinstance(output_text, str) else output_text + elif part.get('type') == 'input_image': + url = part.get('image_url', '') + if url: + image_urls.append(url) + + if image_urls: + # Multimodal tool content with image(s) + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': [ + {'type': 'input_text', 'text': content}, + *[{'type': 'input_image', 'image_url': url} for url in image_urls], + ], + } + ) + else: + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': content, + } + ) + + elif item_type == 'reasoning': + if not reasoning_format: + continue + + reasoning_text = '' + source_list = item.get('summary', []) or item.get('content', []) + for part in source_list: + if part.get('type') == 'output_text': + reasoning_text += part.get('text', '') + elif 'text' in part: + reasoning_text += part.get('text', '') + + if reasoning_text: + if reasoning_format == 'think_tags': + # Ollama: embed in content with the item's original tags + start_tag = item.get('start_tag', '') + end_tag = item.get('end_tag', '') + pending_content.append(f'{start_tag}{reasoning_text}{end_tag}') + elif reasoning_format == 'reasoning_content': + # llama.cpp: collect for reasoning_content field + pending_reasoning.append(reasoning_text) + + elif item_type == 'open_webui:code_interpreter': + # Always include code interpreter content so the LLM knows + # the code was already executed and doesn't retry. + code = item.get('code', '') + code_output = item.get('output', '') + + if code: + pending_content.append(f'\n{code}\n') + + if code_output: + if isinstance(code_output, dict): + stdout = code_output.get('stdout', '') + result = code_output.get('result', '') + output_text = stdout or result + else: + output_text = str(code_output) + if output_text: + pending_content.append(f'\n{output_text}\n') + + elif item_type.startswith('open_webui:'): + # Skip other extension types + pass + + # Flush remaining content/tool_calls + flush_pending() + + return reconcile_tool_pairs(messages) + + +def get_last_user_message(messages: list[dict]) -> str | None: + message = get_last_user_message_item(messages) + if message is None: + return None + return get_content_from_message(message) + + +def set_last_user_message_content(content: str, messages: list[dict]) -> list[dict]: + """ + Replace the text content of the last user message in-place. + Handles both plain-string and list-of-parts content formats. + """ + for message in reversed(messages): + if message.get('role') == 'user': + if isinstance(message.get('content'), list): + for item in message['content']: + if item.get('type') == 'text': + item['text'] = content + break + else: + message['content'] = content + break + return messages + + +def get_last_assistant_message_item(messages: list[dict]) -> dict | None: + for message in reversed(messages): + if message['role'] == 'assistant': + return message + return None + + +def get_last_assistant_message(messages: list[dict]) -> str | None: + for message in reversed(messages): + if message['role'] == 'assistant': + return get_content_from_message(message) + return None + + +def get_system_message(messages: list[dict]) -> dict | None: + for message in messages: + if message['role'] == 'system': + return message + return None + + +def remove_system_message(messages: list[dict]) -> list[dict]: + return [message for message in messages if message['role'] != 'system'] + + +def pop_system_message(messages: list[dict]) -> tuple[dict | None, list[dict]]: + return get_system_message(messages), remove_system_message(messages) + + +def merge_system_messages(messages: list[dict]) -> list[dict]: + """ + Merge all system messages into one at position 0. + + Some chat templates (e.g. Qwen) require exactly one system + message at the start. Multiple pipeline stages may each + insert their own system message; this function consolidates + them. + """ + system_contents: list[str] = [] + other_messages: list[dict] = [] + + for message in messages: + if message.get('role') == 'system': + content = get_content_from_message(message) + if content: + system_contents.append(content) + else: + other_messages.append(message) + + if not system_contents: + return other_messages + + merged = {'role': 'system', 'content': '\n'.join(system_contents)} + return [merged, *other_messages] + + +def update_message_content(message: dict, content: str, append: bool = True) -> dict: + if isinstance(message['content'], list): + for item in message['content']: + if item['type'] == 'text': + if append: + item['text'] = f'{item["text"]}\n{content}' + else: + item['text'] = f'{content}\n{item["text"]}' + else: + if append: + message['content'] = f'{message["content"]}\n{content}' + else: + message['content'] = f'{content}\n{message["content"]}' + return message + + +def replace_system_message_content(content: str, messages: list[dict]) -> dict: + for message in messages: + if message['role'] == 'system': + message['content'] = content + break + return messages + + +def add_or_update_system_message(content: str, messages: list[dict], append: bool = False): + """ + Adds a new system message at the beginning of the messages list + or updates the existing system message at the beginning. + + :param msg: The message to be added or appended. + :param messages: The list of message dictionaries. + :return: The updated list of message dictionaries. + """ + + if messages and messages[0].get('role') == 'system': + messages[0] = update_message_content(messages[0], content, append) + else: + # Insert at the beginning + messages.insert(0, {'role': 'system', 'content': content}) + + return messages + + +def add_or_update_user_message(content: str, messages: list[dict], append: bool = True): + """ + Adds a new user message at the end of the messages list + or updates the existing user message at the end. + + :param msg: The message to be added or appended. + :param messages: The list of message dictionaries. + :return: The updated list of message dictionaries. + """ + + if messages and messages[-1].get('role') == 'user': + messages[-1] = update_message_content(messages[-1], content, append) + else: + # Insert at the end + messages.append({'role': 'user', 'content': content}) + + return messages + + +def prepend_to_first_user_message_content(content: str, messages: list[dict]) -> list[dict]: + for message in messages: + if message['role'] == 'user': + message = update_message_content(message, content, append=False) + break + return messages + + +def append_or_update_assistant_message(content: str, messages: list[dict]): + """ + Adds a new assistant message at the end of the messages list + or updates the existing assistant message at the end. + + :param msg: The message to be added or appended. + :param messages: The list of message dictionaries. + :return: The updated list of message dictionaries. + """ + + if messages and messages[-1].get('role') == 'assistant': + messages[-1]['content'] = f'{messages[-1]["content"]}\n{content}' + else: + # Insert at the end + messages.append({'role': 'assistant', 'content': content}) + + return messages + + +def strip_empty_content_blocks(messages: list[dict]) -> list[dict]: + """ + Remove empty text content blocks from multimodal message content arrays. + + Providers like Gemini and Claude reject messages where a text block has + an empty string. This can happen when a user sends only file/image + attachments without typing any text. + """ + for message in messages: + content = message.get('content') + if isinstance(content, list): + cleaned = [ + block + for block in content + if not (isinstance(block, dict) and block.get('type') == 'text' and not block.get('text', '').strip()) + ] + if cleaned: + message['content'] = cleaned + return messages + + +def openai_chat_message_template(model: str): + return { + 'id': f'{model}-{str(uuid.uuid4())}', + 'created': int(time.time()), + 'model': model, + 'choices': [{'index': 0, 'logprobs': None, 'finish_reason': None}], + } + + +def openai_chat_chunk_message_template( + model: str, + content: str | None = None, + reasoning_content: str | None = None, + tool_calls: list[dict | None] = None, + usage: dict | None = None, +) -> dict: + template = openai_chat_message_template(model) + template['object'] = 'chat.completion.chunk' + + template['choices'][0]['index'] = 0 + template['choices'][0]['delta'] = {} + + if content: + template['choices'][0]['delta']['content'] = content + + if reasoning_content: + template['choices'][0]['delta']['reasoning_content'] = reasoning_content + + if tool_calls: + template['choices'][0]['delta']['tool_calls'] = tool_calls + + if not content and not reasoning_content and not tool_calls: + template['choices'][0]['finish_reason'] = 'stop' + + if usage: + template['usage'] = usage + return template + + +def openai_chat_completion_message_template( + model: str, + message: str | None = None, + reasoning_content: str | None = None, + tool_calls: list[dict | None] = None, + usage: dict | None = None, +) -> dict: + template = openai_chat_message_template(model) + template['object'] = 'chat.completion' + if message is not None: + template['choices'][0]['message'] = { + 'role': 'assistant', + 'content': message, + **({'reasoning_content': reasoning_content} if reasoning_content else {}), + **({'tool_calls': tool_calls} if tool_calls else {}), + } + + template['choices'][0]['finish_reason'] = 'tool_calls' if tool_calls else 'stop' + + if usage: + template['usage'] = usage + return template + + +def get_gravatar_url(email): + # Trim leading and trailing whitespace from + # an email address and force all characters + # to lower case + address = str(email).strip().lower() + + # Create a SHA256 hash of the final string + hash_object = hashlib.sha256(address.encode()) + hash_hex = hash_object.hexdigest() + + # Grab the actual image URL + return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp' + + +# Give us each day the data we require, and forgive us our +# technical debts as we forgive those who commit upstream. +# Lead the bits not into corruption but deliver them from +# entropy, for the checksum and the glory are forever. +def calculate_sha256(file_path, chunk_size): + # Compute SHA-256 hash of a file efficiently in chunks + sha256 = hashlib.sha256() + with open(file_path, 'rb') as f: + while chunk := f.read(chunk_size): + sha256.update(chunk) + return sha256.hexdigest() + + +def calculate_sha256_string(string): + # Create a new SHA-256 hash object + sha256_hash = hashlib.sha256() + # Update the hash object with the bytes of the input string + sha256_hash.update(string.encode('utf-8')) + # Get the hexadecimal representation of the hash + hashed_string = sha256_hash.hexdigest() + return hashed_string + + +def validate_email_format(email: str) -> bool: + if email.endswith('@localhost'): + return True + + return bool(re.match(r'[^@]+@[^@]+\.[^@]+', email)) + + +def sanitize_filename(file_name): + # Convert to lowercase + lower_case_file_name = file_name.lower() + + # Remove special characters using regular expression + sanitized_file_name = re.sub(r'[^\w\s]', '', lower_case_file_name) + + # Replace spaces with dashes + final_file_name = re.sub(r'\s+', '-', sanitized_file_name) + + return final_file_name + + +def sanitize_text_for_db(text: str) -> str: + """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" + if not isinstance(text, str): + return text + # Fast path: skip work when there are no null bytes (the common case) + if '\x00' not in text: + return text + # Remove null bytes + text = text.replace('\x00', '').replace('\u0000', '') + # Remove invalid UTF-8 surrogate characters that can cause encoding errors + # This handles cases where binary data or encoding issues introduced surrogates + try: + text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore') + except (UnicodeEncodeError, UnicodeDecodeError): + pass + return text + + +def _strip_null_bytes_deep(obj): + """Inner recursive walk โ€” only called when null bytes are known to be present.""" + if isinstance(obj, str): + return sanitize_text_for_db(obj) + elif isinstance(obj, dict): + return {k: _strip_null_bytes_deep(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_strip_null_bytes_deep(v) for v in obj] + return obj + + +def sanitize_data_for_db(obj): + """Recursively sanitize all strings in a data structure for database storage. + + Performs a fast pre-check: serializes the structure once and scans for + null bytes. If none are found (the overwhelmingly common case), the + original object is returned immediately, skipping the expensive + recursive walk. + """ + if isinstance(obj, str): + return sanitize_text_for_db(obj) + # Fast path: check for null bytes in the serialized form. + # json.dumps is implemented in C and much faster than a Python-level + # recursive walk over every leaf string. + try: + if '\\u0000' not in json.dumps(obj, ensure_ascii=False): + return obj + except (TypeError, ValueError): + pass + return _strip_null_bytes_deep(obj) + + +def sanitize_metadata(metadata: dict) -> dict: + """ + Return a JSON-safe copy of a metadata dict for database storage. + + The middleware metadata accumulates non-serializable Python objects + (e.g. callable tool functions, MCP client instances) that cause + PostgreSQL JSON inserts to fail. This helper strips those out while + preserving the primitive data needed for file-to-chat linking. + """ + if not isinstance(metadata, dict): + return metadata + + def _sanitize(obj): + if isinstance(obj, (str, int, float, bool, type(None))): + return obj + if isinstance(obj, dict): + return {k: _sanitize(v) for k, v in obj.items() if not callable(v) and _is_serializable(v)} + if isinstance(obj, list): + return [_sanitize(v) for v in obj if not callable(v) and _is_serializable(v)] + if callable(obj): + return None + # Last resort: try to see if it's serializable + try: + json.dumps(obj) + return obj + except (TypeError, ValueError): + return None + + def _is_serializable(obj): + """Quick check whether a value can survive JSON serialization.""" + if isinstance(obj, (str, int, float, bool, type(None), dict, list)): + return True + try: + json.dumps(obj) + return True + except (TypeError, ValueError): + return False + + return _sanitize(metadata) + + +def extract_folders_after_data_docs(path): + # Convert the path to a Path object if it's not already + path = Path(path) + + # Extract parts of the path + parts = path.parts + + # Find the index of '/data/docs' in the path + try: + index_data_docs = parts.index('data') + 1 + index_docs = parts.index('docs', index_data_docs) + 1 + except ValueError: + return [] + + # Exclude the filename and accumulate folder names + tags = [] + + folders = parts[index_docs:-1] + for idx, _ in enumerate(folders): + tags.append('/'.join(folders[: idx + 1])) + + return tags + + +def parse_duration(duration: str) -> timedelta | None: + if duration == '-1' or duration == '0': + return None + + # Regular expression to find number and unit pairs + pattern = r'(-?\d+(\.\d+)?)(ms|s|m|h|d|w)' + matches = re.findall(pattern, duration) + + if not matches: + raise ValueError('Invalid duration string') + + total_duration = timedelta() + + for number, _, unit in matches: + number = float(number) + if unit == 'ms': + total_duration += timedelta(milliseconds=number) + elif unit == 's': + total_duration += timedelta(seconds=number) + elif unit == 'm': + total_duration += timedelta(minutes=number) + elif unit == 'h': + total_duration += timedelta(hours=number) + elif unit == 'd': + total_duration += timedelta(days=number) + elif unit == 'w': + total_duration += timedelta(weeks=number) + + return total_duration + + +def parse_ollama_modelfile(model_text): + parameters_meta = { + 'mirostat': int, + 'mirostat_eta': float, + 'mirostat_tau': float, + 'num_ctx': int, + 'repeat_last_n': int, + 'repeat_penalty': float, + 'temperature': float, + 'seed': int, + 'tfs_z': float, + 'num_predict': int, + 'top_k': int, + 'top_p': float, + 'num_keep': int, + 'presence_penalty': float, + 'frequency_penalty': float, + 'num_batch': int, + 'num_gpu': int, + 'use_mmap': bool, + 'use_mlock': bool, + 'num_thread': int, + } + + data = {'base_model_id': None, 'params': {}} + + # Parse base model + base_model_match = re.search(r'^FROM\s+(\w+)', model_text, re.MULTILINE | re.IGNORECASE) + if base_model_match: + data['base_model_id'] = base_model_match.group(1) + + # Parse template + template_match = re.search(r'TEMPLATE\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE) + if template_match: + data['params'] = {'template': template_match.group(1).strip()} + + # Parse stops + stops = re.findall(r'PARAMETER stop "(.*?)"', model_text, re.IGNORECASE) + if stops: + data['params']['stop'] = stops + + # Parse other parameters from the provided list + for param, param_type in parameters_meta.items(): + param_match = re.search(rf'PARAMETER {param} (.+)', model_text, re.IGNORECASE) + if param_match: + value = param_match.group(1) + + try: + if param_type is int: + value = int(value) + elif param_type is float: + value = float(value) + elif param_type is bool: + value = value.lower() == 'true' + except Exception as e: + log.exception(f'Failed to parse parameter {param}: {e}') + continue + + data['params'][param] = value + + # Parse adapter + adapter_match = re.search(r'ADAPTER (.+)', model_text, re.IGNORECASE) + if adapter_match: + data['params']['adapter'] = adapter_match.group(1) + + # Parse system description + system_desc_match = re.search(r'SYSTEM\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE) + system_desc_match_single = re.search(r'SYSTEM\s+([^\n]+)', model_text, re.IGNORECASE) + + if system_desc_match: + data['params']['system'] = system_desc_match.group(1).strip() + elif system_desc_match_single: + data['params']['system'] = system_desc_match_single.group(1).strip() + + # Parse messages + messages = [] + message_matches = re.findall(r'MESSAGE (\w+) (.+)', model_text, re.IGNORECASE) + for role, content in message_matches: + messages.append({'role': role, 'content': content}) + + if messages: + data['params']['messages'] = messages + + return data + + +def convert_logit_bias_input_to_json(logit_bias_input) -> str | None: + if not logit_bias_input: + return None + + if isinstance(logit_bias_input, dict): + return json.dumps(logit_bias_input) + + logit_bias_pairs = logit_bias_input.split(',') + logit_bias_json = {} + for pair in logit_bias_pairs: + token, bias = pair.split(':') + token = str(token.strip()) + bias = int(bias.strip()) + bias = 100 if bias > 100 else -100 if bias < -100 else bias + logit_bias_json[token] = bias + return json.dumps(logit_bias_json) + + +def freeze(value): + """ + Freeze a value to make it hashable. + """ + if isinstance(value, dict): + return frozenset((k, freeze(v)) for k, v in value.items()) + elif isinstance(value, list): + return tuple(freeze(v) for v in value) + return value + + +def throttle(interval: float = 10.0): + """ + Decorator to prevent a function from being called more than once within a specified duration. + If the function is called again within the duration, it returns None. To avoid returning + different types, the return type of the function should be T | None. + + :param interval: Duration in seconds to wait before allowing the function to be called again. + """ + + def decorator(func): + last_calls = {} + lock = threading.Lock() + + async def wrapper(*args, **kwargs): + if interval is None: + return await func(*args, **kwargs) + + key = (args, freeze(kwargs)) + now = time.time() + if now - last_calls.get(key, 0) < interval: + return None + with lock: + if now - last_calls.get(key, 0) < interval: + return None + last_calls[key] = now + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def strict_match_mime_type(supported: list[str] | str, header: str) -> str | None: + """ + Strictly match the mime type with the supported mime types. + + :param supported: The supported mime types. + :param header: The header to match. + :return: The matched mime type or None if no match is found. + """ + + try: + if isinstance(supported, str): + supported = supported.split(',') + + supported = [s for s in supported if s.strip() and '/' in s] + + if len(supported) == 0: + # Default to common types if none are specified + supported = ['audio/*', 'video/webm'] + + match = mimeparse.best_match(supported, header) + if not match: + return None + + _, _, match_params = mimeparse.parse_mime_type(match) + _, _, header_params = mimeparse.parse_mime_type(header) + for k, v in match_params.items(): + if header_params.get(k) != v: + return None + + return match + except Exception as e: + log.exception(f'Failed to match mime type {header}: {e}') + return None + + +def extract_urls(text: str) -> list[str]: + # Regex pattern to match URLs + url_pattern = re.compile(r'(https?://[^\s]+)', re.IGNORECASE) # Matches http and https URLs + return url_pattern.findall(text) + + +# We believe in one architect of all that is seen and served. +# Should this stream falter, it shall be raised again on the +# third retry. We look for the uptime of the world to come. +async def cleanup_response( + response: aiohttp.ClientResponse | None, + session: aiohttp.ClientSession | None, +): + if response: + if not response.closed: + # aiohttp 3.9+ made ClientResponse.close() synchronous (returns None). + # Older versions returned a coroutine. Handle both gracefully. + result = response.close() + if result is not None: + await result + if session: + if not session.closed: + result = session.close() + if result is not None: + await result + + +async def stream_wrapper(response, session, content_handler=None): + """ + Wrap a stream to ensure cleanup happens even if streaming is interrupted. + This is more reliable than BackgroundTask which may not run if client disconnects. + """ + try: + stream = content_handler(response.content) if content_handler else response.content + async for chunk in stream: + yield chunk + finally: + await cleanup_response(response, session) + + +def stream_chunks_handler(stream: aiohttp.StreamReader): + """ + Handle stream response chunks, supporting large data chunks that exceed the original 16kb limit. + When a single line exceeds max_buffer_size, returns an empty JSON string {} and skips subsequent data + until encountering normally sized data. + + :param stream: The stream reader to handle. + :return: An async generator that yields the stream data. + """ + + max_buffer_size = CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE + if max_buffer_size is None or max_buffer_size <= 0: + return stream + + async def yield_safe_stream_chunks(): + buffer = b'' + skip_mode = False + + async for data, _ in stream.iter_chunks(): + if not data: + continue + + # In skip_mode, if buffer already exceeds the limit, clear it (it's part of an oversized line) + if skip_mode and len(buffer) > max_buffer_size: + buffer = b'' + + lines = (buffer + data).split(b'\n') + + # Process complete lines (except the last possibly incomplete fragment) + for i in range(len(lines) - 1): + line = lines[i] + + if skip_mode: + # Skip mode: check if current line is small enough to exit skip mode + if len(line) <= max_buffer_size: + skip_mode = False + yield line + else: + yield b'data: {}\n' + else: + # Normal mode: check if line exceeds limit + if len(line) > max_buffer_size: + skip_mode = True + yield b'data: {}\n' + log.info(f'Skip mode triggered, line size: {len(line)}') + else: + yield line + b'\n' + + # Save the last incomplete fragment + buffer = lines[-1] + + # Check if buffer exceeds limit + if not skip_mode and len(buffer) > max_buffer_size: + skip_mode = True + log.info(f'Skip mode triggered, buffer size: {len(buffer)}') + # Clear oversized buffer to prevent unlimited growth + buffer = b'' + + # Process remaining buffer data + if buffer and not skip_mode: + yield buffer + b'\n' + + return yield_safe_stream_chunks() diff --git a/plugins/filters/async-context-compression/_diag.py b/plugins/filters/async-context-compression/_diag.py new file mode 100644 index 0000000..aeb94bb --- /dev/null +++ b/plugins/filters/async-context-compression/_diag.py @@ -0,0 +1,60 @@ +"""Diagnostic script: trace _load_applicable_summary_snapshot step by step.""" +import asyncio +import json +import sys +sys.path.insert(0, ".") +from test_issue98_e2e import ( + Filter, _build_reasoning_chat_openai_compatible, + _live_refs_by_id, _snapshot, +) + +filter_obj = Filter() +filter_obj.valves.keep_last = 0 +filter_obj._get_model_thresholds = lambda m: {"max_context_tokens": 100000, "compression_threshold_tokens": 1000} + +db_messages, body_messages = _build_reasoning_chat_openai_compatible() +snapshots = [_snapshot("test summary", filter_obj._message_refs_for_prefix(db_messages, 2))] + +print("=== Step 0: Data ===") +print(f"db_messages count: {len(db_messages)}") +print(f"body_messages count: {len(body_messages)}") +print(f"snapshot refs: {json.loads(snapshots[0]['covered_message_refs_json'])}") + +print("\n=== Step 1: _current_branch_refs(body_messages) ===") +body_refs = filter_obj._current_branch_refs(body_messages) +print(f"body_refs: {body_refs}") + +print("\n=== Step 2: _current_branch_refs(db_messages) ===") +db_refs = filter_obj._current_branch_refs(db_messages) +print(f"db_refs: {db_refs}") + +print("\n=== Step 3: _compatible_db_branch_for_body_ref_fallback(body, db) ===") +result = filter_obj._compatible_db_branch_for_body_ref_fallback(body_messages, db_messages) +print(f"result: compatible={result[0] is not None}, boundaries={result[1]}, ignored={result[2]}") +if result[1]: + print(f" boundaries detail: {result[1]}") + +print("\n=== Step 4: _select_applicable_summary_snapshot(snapshots, db_messages) ===") +live_refs = _live_refs_by_id(filter_obj, db_messages) +print(f"live_refs keys: {list(live_refs.keys()) if live_refs else None}") +selected = filter_obj._select_applicable_summary_snapshot( + snapshots, db_messages, live_message_refs_by_id=live_refs, +) +print(f"selected: {selected}") +if selected is None: + # Try with debug_mode on to see rejection reason + filter_obj.valves.debug_mode = True + selected2 = filter_obj._select_applicable_summary_snapshot( + snapshots, db_messages, live_message_refs_by_id=live_refs, + ) + print(f"selected (debug): {selected2}") + filter_obj.valves.debug_mode = False + +print("\n=== Step 5: _message_refs_for_prefix(db_messages, 2) ===") +refs_prefix = filter_obj._message_refs_for_prefix(db_messages, 2) +print(f"refs_prefix: {refs_prefix}") + +print("\n=== Step 6: _message_ref for each db_message ===") +for i, msg in enumerate(db_messages): + ref = filter_obj._message_ref(msg) + print(f" db_message[{i}]: id={msg.get('id')}, ref={ref}") diff --git a/plugins/filters/async-context-compression/test_issue98_e2e.py b/plugins/filters/async-context-compression/test_issue98_e2e.py new file mode 100644 index 0000000..f654b59 --- /dev/null +++ b/plugins/filters/async-context-compression/test_issue98_e2e.py @@ -0,0 +1,782 @@ +"""End-to-end verification for issue #98 using OpenWebUI's REAL functions. + +This test module replaces the dummy ``convert_output_to_messages`` stub with +the **actual** implementation from OpenWebUI's main branch (sourced from +``open_webui/utils/misc.py`` and ``open_webui/utils/middleware.py``), then +reconstructs the exact data flow that happens when a reasoning model chat +hits the inlet: + + DB messages (with output arrays + folded reasoning content) + โ†“ process_messages_with_output(reasoning_format=get_reasoning_format(model)) + body messages (reasoning stripped / tagged / routed, per provider) + โ†“ Filter.inlet() + plugin decides whether to inject the saved summary + +The goal is to prove that Path 3 (position-based fallback) correctly accepts +the snapshot for reasoning models where body content โ‰  DB content, while +still rejecting genuine mismatches (edited bodies, tampered tool calls). +""" + +import asyncio +import importlib +import importlib.util +import json +import os +import sys +import types +import unittest +from copy import deepcopy + +# โ”€โ”€ OpenWebUI real functions (sourced from main branch) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Source: open_webui/utils/misc.py (convert_output_to_messages, reconcile_tool_pairs) +# Source: open_webui/utils/middleware.py (process_messages_with_output, get_reasoning_format) +# These are copied verbatim to avoid importing the full OpenWebUI stack. + + +def _owui_reconcile_tool_pairs(messages): + """Drop unpaired tool_use / tool_result from a reconstructed conversation.""" + completed_tool_call_ids = { + message['tool_call_id'] + for message in messages + if message.get('role') == 'tool' and message.get('tool_call_id') + } + requested_tool_call_ids = { + tool_call['id'] + for message in messages + for tool_call in message.get('tool_calls') or () + if message.get('role') == 'assistant' and tool_call.get('id') + } + + reconciled_messages = [] + for message in messages: + role = message.get('role') + if role != 'assistant' or not message.get('tool_calls'): + reconciled_messages.append(message) + continue + + valid_tool_calls = [ + tc for tc in message['tool_calls'] if tc.get('id') in completed_tool_call_ids + ] + if valid_tool_calls: + reconciled_messages.append({**message, 'tool_calls': valid_tool_calls}) + continue + + content = message.get('content', '') + has_meaningful_content = content.strip() if isinstance(content, str) else content + if has_meaningful_content or message.get('reasoning_content'): + reconciled_messages.append( + {k: v for k, v in message.items() if k != 'tool_calls'} + ) + return reconciled_messages + + +def _owui_convert_output_to_messages(output, raw=False, reasoning_format=None): + """REAL OpenWebUI convert_output_to_messages from misc.py.""" + if not output or not isinstance(output, list): + return [] + + messages = [] + pending_tool_calls = [] + pending_content = [] + pending_reasoning = [] + + def flush_pending(): + nonlocal pending_content, pending_tool_calls, pending_reasoning + if not pending_content and not pending_tool_calls and not pending_reasoning: + return + message = { + 'role': 'assistant', + 'content': '\n'.join(pending_content) if pending_content else '', + **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), + } + if pending_reasoning: + message['reasoning_content'] = '\n'.join(pending_reasoning) + messages.append(message) + pending_content = [] + pending_tool_calls = [] + pending_reasoning = [] + + for item in output: + item_type = item.get('type', '') + + if item_type == 'message': + content_parts = item.get('content', []) + text = '' + for part in content_parts: + if part.get('type') == 'output_text': + text += part.get('text', '') + if text: + pending_content.append(text) + + elif item_type == 'function_call': + arguments = item.get('arguments', '{}') + if not isinstance(arguments, str): + arguments = json.dumps(arguments) + pending_tool_calls.append({ + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': {'name': item.get('name', ''), 'arguments': arguments}, + }) + + elif item_type == 'function_call_output': + flush_pending() + output_parts = item.get('output', []) + content = '' + for part in output_parts: + if part.get('type') == 'input_text': + output_text = part.get('text', '') + content += str(output_text) if not isinstance(output_text, str) else output_text + messages.append({ + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': content, + }) + + elif item_type == 'reasoning': + if not reasoning_format: + continue + reasoning_text = '' + source_list = item.get('summary', []) or item.get('content', []) + for part in source_list: + if part.get('type') == 'output_text': + reasoning_text += part.get('text', '') + elif 'text' in part: + reasoning_text += part.get('text', '') + if reasoning_text: + if reasoning_format == 'think_tags': + start_tag = item.get('start_tag', '') + end_tag = item.get('end_tag', '') + pending_content.append(f'{start_tag}{reasoning_text}{end_tag}') + elif reasoning_format == 'reasoning_content': + pending_reasoning.append(reasoning_text) + + elif item_type == 'open_webui:code_interpreter': + code = item.get('code', '') + code_output = item.get('output', '') + if code: + pending_content.append(f'\n{code}\n') + if code_output: + if isinstance(code_output, dict): + output_text = code_output.get('stdout', '') or code_output.get('result', '') + else: + output_text = str(code_output) + if output_text: + pending_content.append(f'\n{output_text}\n') + + elif item_type.startswith('open_webui:'): + pass + + flush_pending() + return _owui_reconcile_tool_pairs(messages) + + +def _owui_process_messages_with_output(messages, reasoning_format=None): + """REAL OpenWebUI process_messages_with_output from middleware.py.""" + processed = [] + for message in messages: + if message.get('role') == 'assistant' and message.get('output'): + output_messages = _owui_convert_output_to_messages( + message['output'], raw=True, reasoning_format=reasoning_format + ) + if output_messages: + processed.extend(output_messages) + continue + clean_message = {k: v for k, v in message.items() if k != 'output'} + processed.append(clean_message) + return processed + + +def _strip_ids(messages): + """Simulate the OpenWebUI frontend: request body carries no message node ids. + + ``process_messages_with_output`` itself only strips ``output``; it does NOT + remove ``id``. But the request body that hits the inlet filter has no + ``id`` fields โ€” the frontend sends plain chat-completion messages. This + helper strips ids after reconstruction to match the real inlet payload. + """ + result = [] + for msg in messages: + clean = {k: v for k, v in msg.items() if k != 'id' and k != 'message_id'} + result.append(clean) + return result + + +def _build_body_from_db(db_messages, reasoning_format=None): + """Simulate the full OpenWebUI inlet pipeline: + + 1. process_messages_with_output (rebuilds assistant content from output) + 2. strip message node ids (frontend sends plain chat-completion messages) + """ + rebuilt = _owui_process_messages_with_output( + deepcopy(db_messages), reasoning_format=reasoning_format + ) + return _strip_ids(rebuilt) + + +def _owui_get_reasoning_format(model): + """REAL OpenWebUI get_reasoning_format from middleware.py.""" + provider = model.get('provider', '') if isinstance(model, dict) else '' + if provider == 'ollama': + return 'think_tags' + if provider == 'llama.cpp': + return 'reasoning_content' + return None + + +# โ”€โ”€ Plugin loading (reuse the stub machinery from the main test file) โ”€โ”€ + +PLUGIN_PATH = os.path.join(os.path.dirname(__file__), "async_context_compression.py") +MODULE_NAME = "async_context_compression_issue98_e2e" + + +def _ensure_module(name): + module = sys.modules.get(name) + if module is None: + module = types.ModuleType(name) + sys.modules[name] = module + return module + + +def _install_stubs(): + """Install stubs but with the REAL convert_output_to_messages.""" + pydantic_module = _ensure_module("pydantic") + sqlalchemy_module = _ensure_module("sqlalchemy") + sqlalchemy_orm_module = _ensure_module("sqlalchemy.orm") + sqlalchemy_engine_module = _ensure_module("sqlalchemy.engine") + + class DummyBaseModel: + def __init__(self, **kwargs): + annotations = getattr(self.__class__, "__annotations__", {}) + for field_name in annotations: + value = kwargs.get(field_name, getattr(self.__class__, field_name, None)) + setattr(self, field_name, value) + + def dummy_field(default=None, **kwargs): + return default + + class DummyMetadata: + def create_all(self, *args, **kwargs): + return None + + def dummy_declarative_base(): + class DummyBase: + metadata = DummyMetadata() + return DummyBase + + def dummy_sessionmaker(*args, **kwargs): + return lambda: None + + class DummyEngine: + pass + + class DummyMetaData: + pass + + class DummyTable: + def __init__(self, *args, **kwargs): + pass + def drop(self, *args, **kwargs): + return None + + class DummyIndex: + def __init__(self, name, *args, **kwargs): + self.name = name + def create(self, *args, **kwargs): + return None + + def dummy_column(*args, **kwargs): + return None + + def dummy_type(*args, **kwargs): + return None + + def dummy_inspect(*args, **kwargs): + return types.SimpleNamespace(has_table=lambda *a, **k: False) + + pydantic_module.BaseModel = DummyBaseModel + pydantic_module.Field = dummy_field + sqlalchemy_module.Column = dummy_column + sqlalchemy_module.String = dummy_type + sqlalchemy_module.Text = dummy_type + sqlalchemy_module.DateTime = dummy_type + sqlalchemy_module.Integer = dummy_type + sqlalchemy_module.Index = DummyIndex + sqlalchemy_module.MetaData = DummyMetaData + sqlalchemy_module.Table = DummyTable + sqlalchemy_module.inspect = dummy_inspect + sqlalchemy_orm_module.declarative_base = dummy_declarative_base + sqlalchemy_orm_module.sessionmaker = dummy_sessionmaker + sqlalchemy_engine_module.Engine = DummyEngine + + # OpenWebUI stubs โ€” but with REAL convert_output_to_messages + _ensure_module("open_webui") + _ensure_module("open_webui.utils") + chat_module = _ensure_module("open_webui.utils.chat") + misc_module = _ensure_module("open_webui.utils.misc") + _ensure_module("open_webui.models") + _ensure_module("open_webui.models.users") + _ensure_module("open_webui.models.models") + _ensure_module("open_webui.models.chats") + _ensure_module("open_webui.main") + _ensure_module("fastapi") + fastapi_requests = _ensure_module("fastapi.requests") + + async def generate_chat_completion(*args, **kwargs): + return {} + + class DummyUsers: + pass + + class DummyModels: + @staticmethod + def get_model_by_id(model_id): + return None + + class DummyChats: + @staticmethod + def get_chat_by_id(chat_id): + return None + + class DummyRequest: + def __init__(self, *args, **kwargs): + pass + + chat_module.generate_chat_completion = generate_chat_completion + # KEY: install the REAL convert_output_to_messages, not the dummy + misc_module.convert_output_to_messages = _owui_convert_output_to_messages + _ensure_module("open_webui.models.users").Users = DummyUsers + _ensure_module("open_webui.models.models").Models = DummyModels + _ensure_module("open_webui.models.chats").Chats = DummyChats + _ensure_module("open_webui.main").app = object() + fastapi_requests.Request = DummyRequest + + +_install_stubs() +spec = importlib.util.spec_from_file_location(MODULE_NAME, PLUGIN_PATH) +module = importlib.util.module_from_spec(spec) +sys.modules[MODULE_NAME] = module +spec.loader.exec_module(module) +module.Filter._init_database = lambda self: None + +Filter = module.Filter + + +# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _live_refs_by_id(filter_obj, messages): + refs = filter_obj._message_refs_for_prefix(messages, len(messages)) + if refs is None: + return {} + return {ref["id"]: ref for ref in refs} + + +def _snapshot(content, refs, protected_head_count=0): + """Build a snapshot object that mirrors the ChatSummary ORM row. + + The plugin reads snapshot fields via ``getattr`` (e.g. + ``covered_message_refs_json``, ``compressed_message_count``) and writes + selection metadata via ``setattr`` (``_annotate_summary_snapshot_selection``). + A plain dict therefore cannot work โ€” ``types.SimpleNamespace`` matches the + ORM attribute-access contract, exactly like the main test suite's helper. + """ + refs_payload = refs + if protected_head_count > 0: + refs_payload = { + "refs": refs, + "protected_head_count": protected_head_count, + } + return types.SimpleNamespace( + summary=content, + compressed_message_count=len(refs), + covered_message_refs_json=json.dumps( + refs_payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ), + covered_refs_hash="hash", + branch_tip_id=refs[-1]["id"] if refs else None, + updated_at=None, + created_at=None, + ) + + +# โ”€โ”€ Test data builders simulating real reasoning-model chats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _build_reasoning_chat_openai_compatible(): + """Build DB messages + body messages for an OpenAI-compatible reasoning model. + + reasoning_format = None (default for non-ollama/llama.cpp providers). + DB assistant content contains a folded
block. + Body assistant content has reasoning stripped by convert_output_to_messages. + """ + db_messages = [ + {"id": "m0", "role": "user", "content": "What is 2+2?"}, + { + "id": "m1", + "role": "assistant", + "content": '
\nLet me think... 2+2=4\n
\nThe answer is 4.', + "output": [ + { + "type": "reasoning", + "summary": [{"type": "output_text", "text": "Let me think... 2+2=4"}], + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "The answer is 4."}], + }, + ], + }, + {"id": "m2", "role": "user", "content": "Thanks!"}, + ] + # Simulate what OpenWebUI does before inlet: process_messages_with_output + # + strip ids (frontend sends plain chat-completion messages without ids) + body_messages = _build_body_from_db(db_messages, reasoning_format=None) + return db_messages, body_messages + + +def _build_reasoning_chat_ollama(): + """Build DB + body for an Ollama reasoning model (reasoning_format='think_tags').""" + db_messages = [ + {"id": "m0", "role": "user", "content": "Explain recursion."}, + { + "id": "m1", + "role": "assistant", + "content": '
\nRecursion is self-reference...\n
\nRecursion is when a function calls itself.', + "output": [ + { + "type": "reasoning", + "summary": [{"type": "output_text", "text": "Recursion is self-reference..."}], + "start_tag": "", + "end_tag": "", + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "Recursion is when a function calls itself."}], + }, + ], + }, + {"id": "m2", "role": "user", "content": "Got it."}, + ] + body_messages = _build_body_from_db(db_messages, reasoning_format="think_tags") + return db_messages, body_messages + + +def _build_reasoning_chat_llamacpp(): + """Build DB + body for a llama.cpp reasoning model (reasoning_format='reasoning_content').""" + db_messages = [ + {"id": "m0", "role": "user", "content": "Why is the sky blue?"}, + { + "id": "m1", + "role": "assistant", + "content": '
\nRayleigh scattering...\n
\nThe sky appears blue due to Rayleigh scattering.', + "output": [ + { + "type": "reasoning", + "summary": [{"type": "output_text", "text": "Rayleigh scattering..."}], + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "The sky appears blue due to Rayleigh scattering."}], + }, + ], + }, + {"id": "m2", "role": "user", "content": "Interesting."}, + ] + body_messages = _build_body_from_db(db_messages, reasoning_format="reasoning_content") + return db_messages, body_messages + + +def _build_reasoning_with_tool_calls(): + """Build DB + body for a reasoning model that also calls a tool. + + The output array contains: reasoning โ†’ function_call โ†’ function_call_output โ†’ message. + After process_messages_with_output, the single DB assistant message expands into + multiple body messages (assistant+tool_calls, tool result, assistant text). + So body count > DB count โ€” Path 3 won't fire, but Path 2 (unfolded) should match. + """ + db_messages = [ + {"id": "m0", "role": "user", "content": "What's the weather in Tokyo?"}, + { + "id": "m1", + "role": "assistant", + "content": '
\nI need to check the weather.\n
\nThe weather in Tokyo is 25ยฐC and sunny.', + "output": [ + { + "type": "reasoning", + "summary": [{"type": "output_text", "text": "I need to check the weather."}], + }, + { + "type": "function_call", + "call_id": "call_w1", + "name": "get_weather", + "arguments": '{"city": "Tokyo"}', + }, + { + "type": "function_call_output", + "call_id": "call_w1", + "output": [{"type": "input_text", "text": "25ยฐC, sunny"}], + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "The weather in Tokyo is 25ยฐC and sunny."}], + }, + ], + }, + {"id": "m2", "role": "user", "content": "Great, thanks!"}, + ] + body_messages = _build_body_from_db(db_messages, reasoning_format=None) + return db_messages, body_messages + + +# โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestIssue98E2E(unittest.TestCase): + """End-to-end verification using OpenWebUI's real convert_output_to_messages.""" + + def setUp(self): + self.filter = Filter() + self.filter.valves.keep_last = 0 + + # โ”€โ”€ Scenario 1: OpenAI-compatible reasoning model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_openai_reasoning_body_content_differs_from_db(self): + """Verify the premise: body content (reasoning stripped) โ‰  DB content.""" + db_messages, body_messages = _build_reasoning_chat_openai_compatible() + db_content = db_messages[1]["content"] + body_content = body_messages[1]["content"] + self.assertIn("
", db_content) + self.assertNotIn("
", body_content) + self.assertNotEqual(db_content, body_content) + + def test_openai_reasoning_path1_folded_fails(self): + """Path 1 (folded content match) must fail because content differs.""" + db_messages, body_messages = _build_reasoning_chat_openai_compatible() + result = self.filter._body_message_matches_db_branch_message( + body_messages[1], db_messages[1] + ) + self.assertFalse(result) + + def test_openai_reasoning_path3_position_accepts(self): + """Path 3 (position-based) must accept despite content difference.""" + db_messages, body_messages = _build_reasoning_chat_openai_compatible() + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + self.assertIsNotNone(coverage, "Path 3 should accept reasoning model body") + self.assertEqual(coverage, [0, 1, 2, 3]) + + def test_openai_reasoning_inlet_injects_summary(self): + """Full inlet call must inject the summary for OpenAI-compatible reasoning.""" + db_messages, body_messages = _build_reasoning_chat_openai_compatible() + snapshots = [ + _snapshot( + "openai reasoning summary", + self.filter._message_refs_for_prefix(db_messages, 2), + ) + ] + + async def fake_load_snapshots(chat_id): + return snapshots + + async def fake_load_live_refs(chat_id): + return _live_refs_by_id(self.filter, db_messages) + + async def fake_load_full_chat_messages(chat_id): + return db_messages + + async def noop(*args, **kwargs): + return None + + self.filter._load_summary_snapshots = fake_load_snapshots + self.filter._load_chat_history_live_refs = fake_load_live_refs + self.filter._load_full_chat_messages = fake_load_full_chat_messages + self.filter._log = noop + self.filter._emit_debug_log = noop + self.filter._get_model_thresholds = lambda model_id: { + "max_context_tokens": 100000, + "compression_threshold_tokens": 1000, + } + + # Diagnose: call _load_applicable_summary_snapshot directly + snapshot = asyncio.run(self.filter._load_applicable_summary_snapshot( + "chat-openai-reasoning", body_messages, + )) + self.assertIsNotNone( + snapshot, + f"Snapshot must be selected. body_messages={body_messages}, db_messages={db_messages}", + ) + + result = asyncio.run(self.filter.inlet({ + "chat_id": "chat-openai-reasoning", + "model": "test-model", + "messages": body_messages, + })) + + self.assertTrue( + self.filter._is_summary_message(result["messages"][0]), + f"Summary must be injected at position 0. Got: {result['messages']}", + ) + self.assertIn("openai reasoning summary", result["messages"][0]["content"]) + + # โ”€โ”€ Scenario 2: Ollama reasoning model (think_tags) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_ollama_reasoning_body_has_think_tags_not_details(self): + """Ollama: body content has tags, DB has
.""" + db_messages, body_messages = _build_reasoning_chat_ollama() + self.assertIn("
", db_messages[1]["content"]) + self.assertIn("", body_messages[1]["content"]) + self.assertNotIn("
", body_messages[1]["content"]) + + def test_ollama_reasoning_path3_accepts(self): + db_messages, body_messages = _build_reasoning_chat_ollama() + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + self.assertIsNotNone(coverage) + self.assertEqual(coverage, [0, 1, 2, 3]) + + # โ”€โ”€ Scenario 3: llama.cpp reasoning model (reasoning_content) โ”€โ”€โ”€โ”€โ”€ + + def test_llamacpp_reasoning_body_has_reasoning_content_field(self): + """llama.cpp: body has reasoning_content field, DB does not.""" + db_messages, body_messages = _build_reasoning_chat_llamacpp() + self.assertNotIn("reasoning_content", db_messages[1]) + self.assertIn("reasoning_content", body_messages[1]) + + def test_llamacpp_reasoning_path3_accepts(self): + db_messages, body_messages = _build_reasoning_chat_llamacpp() + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + self.assertIsNotNone(coverage) + self.assertEqual(coverage, [0, 1, 2, 3]) + + # โ”€โ”€ Scenario 4: Reasoning + tool calls (count mismatch, Path 2) โ”€โ”€โ”€ + + def test_reasoning_with_tool_calls_body_count_differs(self): + """When output has function_call, body count > DB count (expanded).""" + db_messages, body_messages = _build_reasoning_with_tool_calls() + self.assertEqual(len(db_messages), 3) + # DB assistant expands to: assistant(tool_calls) + tool(result) + assistant(text) + self.assertGreater(len(body_messages), 3) + + def test_reasoning_with_tool_calls_path3_rejects_count_mismatch(self): + """Path 3 must reject because body count โ‰  DB count (tool expansion).""" + db_messages, body_messages = _build_reasoning_with_tool_calls() + # Path 3 checks len(body) == len(db), which fails here. + # But Path 2 (unfolded) should handle this via _unfold_db_branch_for_body_ref_fallback. + # We verify the overall fallback still returns a valid coverage map. + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + # Path 2 should succeed: unfolded DB matches body 1:1 + self.assertIsNotNone(coverage, "Path 2 (unfolded) should handle tool-call expansion") + + # โ”€โ”€ Scenario 5: Negative โ€” edited body with no DB output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_edited_body_no_output_rejected(self): + """If DB has no output array and body content was edited, reject.""" + db_messages = [ + {"id": "m0", "role": "user", "content": "original"}, + {"id": "m1", "role": "assistant", "content": "original answer"}, + {"id": "m2", "role": "user", "content": "follow up"}, + ] + body_messages = [ + {"role": "user", "content": "original"}, + {"role": "assistant", "content": "EDITED answer"}, # tampered + {"role": "user", "content": "follow up"}, + ] + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + self.assertIsNone(coverage, "Must reject edited body when DB has no output") + + # โ”€โ”€ Scenario 6: Negative โ€” tampered tool_calls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_tampered_tool_calls_rejected(self): + """If body tool_calls differ from DB, Path 3 must reject.""" + db_messages = [ + {"id": "m0", "role": "user", "content": "q"}, + { + "id": "m1", + "role": "assistant", + "content": "answer", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "search", "arguments": "{}"}}], + "output": [{"type": "message", + "content": [{"type": "output_text", "text": "answer"}]}], + }, + {"id": "m2", "role": "user", "content": "next"}, + ] + body_messages = [ + {"role": "user", "content": "q"}, + { + "role": "assistant", + "content": "answer", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "DIFFERENT", "arguments": "{}"}}], + }, + {"role": "user", "content": "next"}, + ] + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + self.assertIsNone(coverage, "Must reject tampered tool_calls") + + # โ”€โ”€ Scenario 7: Verify body has no ids (precondition for Path 3) โ”€โ”€ + + def test_openai_reasoning_body_has_no_message_ids(self): + """Verify that process_messages_with_output strips ids from body.""" + db_messages, body_messages = _build_reasoning_chat_openai_compatible() + for msg in body_messages: + self.assertIsNone( + self.filter._get_message_id(msg), + "Body messages must not carry OpenWebUI ids (plain chat)", + ) + + # โ”€โ”€ Scenario 8: Full inlet for Ollama reasoning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_ollama_reasoning_inlet_injects_summary(self): + db_messages, body_messages = _build_reasoning_chat_ollama() + snapshots = [ + _snapshot( + "ollama reasoning summary", + self.filter._message_refs_for_prefix(db_messages, 2), + ) + ] + + async def fake_load_snapshots(chat_id): + return snapshots + + async def fake_load_live_refs(chat_id): + return _live_refs_by_id(self.filter, db_messages) + + async def fake_load_full_chat_messages(chat_id): + return db_messages + + async def noop(*args, **kwargs): + return None + + self.filter._load_summary_snapshots = fake_load_snapshots + self.filter._load_chat_history_live_refs = fake_load_live_refs + self.filter._load_full_chat_messages = fake_load_full_chat_messages + self.filter._log = noop + self.filter._emit_debug_log = noop + self.filter._get_model_thresholds = lambda model_id: { + "max_context_tokens": 100000, + "compression_threshold_tokens": 1000, + } + + result = asyncio.run(self.filter.inlet({ + "chat_id": "chat-ollama-reasoning", + "model": "test-model", + "messages": body_messages, + })) + + self.assertTrue(self.filter._is_summary_message(result["messages"][0])) + self.assertIn("ollama reasoning summary", result["messages"][0]["content"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From e1a082b8c365fc0d43a332ad87a6213c5757dcd4 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 14:46:43 +0000 Subject: [PATCH 09/16] chore: bump async-context-compression to v1.7.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps version 1.7.2 โ†’ 1.7.3 and ships three fixes plus end-to-end verification: - Summary persistence on fresh PostgreSQL: dedup stale unique-index definitions in shared SQLAlchemy metadata before CREATE TABLE, and idempotently clear legacy colliding indexes. Resolves DuplicateTable: relation "ix_chat_summary_chat_id" already exists and the resulting โš ๏ธ Summary generated but was not persisted. - Outlet summary reuse for idless plain-chat branches: when the outlet request carries a chat_id but no stable message refs, read the active DB branch and align the body via _compatible_db_branch_for_body_ref_fallback so the generated summary can be persisted and reused. - Reasoning-model inlet reuse (issue #98): new position-based fallback (Path 3) accepts the snapshot when body and DB branches have equal length and roles / tool_calls / tool_call_id match position-by-position. DB messages with an output array are exempted from content comparison; DB messages without output still require exact content equality (fail-closed against edits). - Path 3 mixed-id fix: process_messages_with_output only strips output (not id), so real reasoning-chat bodies are mixed-id. The all-idless guard was removed โ€” reaching Path 3 already requires _current_branch_refs(messages) is None upstream, so the guard was both wrong and redundant. - Path 3 diagnostic logging: when Path 3 is eligible but a per-position check fails, debug_mode now logs the first failing index and mismatched field (role / tool_calls / tool_call_id / content-on-no-output). - End-to-end verification: test_issue98_e2e.py inlines convert_output_to_messages, process_messages_with_output, and reconcile_tool_pairs copied verbatim from the OpenWebUI main branch. The body builder mirrors the real pipeline exactly (genuinely mixed-id), replaying OpenAI-compatible / Ollama / llama.cpp / tool-call reasoning chats through the full inlet() entry point. 15/15 e2e tests pass. Also adds v1.7.3 release notes (EN + CN), updates README/README_CN, and removes the temporary _diag.py diagnostic script. --- .../async-context-compression/README.md | 13 +++- .../async-context-compression/README_CN.md | 13 +++- .../async-context-compression/_diag.py | 60 --------------- .../async_context_compression.py | 74 +++++++++++++++++-- .../test_issue98_e2e.py | 66 ++++++++++------- .../async-context-compression/v1.7.3.md | 27 +++++++ .../async-context-compression/v1.7.3_CN.md | 27 +++++++ 7 files changed, 183 insertions(+), 97 deletions(-) delete mode 100644 plugins/filters/async-context-compression/_diag.py create mode 100644 plugins/filters/async-context-compression/v1.7.3.md create mode 100644 plugins/filters/async-context-compression/v1.7.3_CN.md diff --git a/plugins/filters/async-context-compression/README.md b/plugins/filters/async-context-compression/README.md index 9a5a3d7..a13fc97 100644 --- a/plugins/filters/async-context-compression/README.md +++ b/plugins/filters/async-context-compression/README.md @@ -1,6 +1,6 @@ # Async Context Compression Filter -| By [Fu-Jie](https://github.com/Fu-Jie) ยท v1.7.2 | [โญ Star this repo](https://github.com/Fu-Jie/openwebui-extensions) | +| By [Fu-Jie](https://github.com/Fu-Jie) ยท v1.7.3 | [โญ Star this repo](https://github.com/Fu-Jie/openwebui-extensions) | | :--- | ---: | | ![followers](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_followers.json&label=%F0%9F%91%A5&style=flat) | ![points](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_points.json&label=%E2%AD%90&style=flat) | ![top](https://img.shields.io/badge/%F0%9F%8F%86-Top%20%3C1%25-10b981?style=flat) | ![contributions](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_contributions.json&label=%F0%9F%93%A6&style=flat) | ![downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_downloads.json&label=%E2%AC%87%EF%B8%8F&style=flat) | ![saves](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_saves.json&label=%F0%9F%92%BE&style=flat) | ![views](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_views.json&label=%F0%9F%91%81%EF%B8%8F&style=flat) | @@ -28,6 +28,15 @@ When the selection dialog opens, search for this plugin, check it, and continue. - **Protected-head tracking**: Summary rows remember how many leading messages were kept outside the summary. If the current `keep_first` policy no longer preserves those messages, the row is not reused as branch-valid coverage. - **Safe upgrade behavior**: Legacy summaries without coverage metadata are not trusted as coverage. The first turn after upgrading may send more raw context until a branch-valid summary row is generated. +## What's new in 1.7.3 + +- **Summary persistence on fresh PostgreSQL**: Fixed `DuplicateTable: relation "ix_chat_summary_chat_id" already exists` and the resulting `โš ๏ธ Summary generated but was not persisted`. The shared SQLAlchemy metadata is now deduplicated before `CREATE TABLE`, and legacy colliding indexes are cleared idempotently. +- **Outlet summary reuse for idless plain-chat branches**: When the outlet request carries a `chat_id` but no stable message refs, the filter now reads the active DB branch and aligns the body against it so the generated summary can be persisted and reused on subsequent turns. +- **Reasoning-model inlet reuse (issue #98)**: Reasoning models store folded `
` content in the DB, but the request body reconstructed by `process_messages_with_output` strips or re-tags the reasoning, so cached summaries were rejected every turn. A new position-based fallback (Path 3) accepts the snapshot when body and DB branches have equal length and roles / tool_calls / tool_call_id match position-by-position. DB messages with an `output` array are exempted from content comparison; DB messages without `output` still require exact content equality, so edited or tampered bodies are rejected. +- **Path 3 mixed-id fix**: `process_messages_with_output` only strips `output` (not `id`), so real reasoning-chat bodies are mixed-id โ€” the all-idless guard was removed; Path 3 now accepts real reasoning chats. +- **Path 3 diagnostic logging**: when Path 3 is eligible but a per-position check fails, `debug_mode` now logs the first failing index and the mismatched field (role / tool_calls / tool_call_id / content), so silent rejections are observable. +- **End-to-end verification**: A new test module inlines `convert_output_to_messages`, `process_messages_with_output`, and `reconcile_tool_pairs` copied verbatim from the OpenWebUI main branch, and replays OpenAI-compatible / Ollama / llama.cpp / tool-call reasoning chats through the full `inlet()` entry point. The body builder mirrors the real pipeline exactly (genuinely mixed-id), with regression tests covering the mixed-id shape and Path 3 acceptance. + ## What's new in 1.7.2 - **Summary injection safety guard**: Injected summaries now explicitly state that goals, open loops, and tool state inside the summary are historical context only, not new instructions. @@ -221,6 +230,6 @@ If this plugin has been useful, a star on [OpenWebUI Extensions](https://github. ## Changelog -See [`v1.7.2` Release Notes](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/filters/async-context-compression/v1.7.2.md) for the release-specific summary. +See [`v1.7.3` Release Notes](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/filters/async-context-compression/v1.7.3.md) for the release-specific summary. See the full history on GitHub: [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) diff --git a/plugins/filters/async-context-compression/README_CN.md b/plugins/filters/async-context-compression/README_CN.md index a395be2..0370af5 100644 --- a/plugins/filters/async-context-compression/README_CN.md +++ b/plugins/filters/async-context-compression/README_CN.md @@ -1,6 +1,6 @@ # ๅผ‚ๆญฅไธŠไธ‹ๆ–‡ๅŽ‹็ผฉ่ฟ‡ๆปคๅ™จ -| ไฝœ่€…๏ผš[Fu-Jie](https://github.com/Fu-Jie) ยท v1.7.2 | [โญ ็‚นไธช Star ๆ”ฏๆŒ้กน็›ฎ](https://github.com/Fu-Jie/openwebui-extensions) | +| ไฝœ่€…๏ผš[Fu-Jie](https://github.com/Fu-Jie) ยท v1.7.3 | [โญ ็‚นไธช Star ๆ”ฏๆŒ้กน็›ฎ](https://github.com/Fu-Jie/openwebui-extensions) | | :--- | ---: | | ![followers](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_followers.json&label=%F0%9F%91%A5&style=flat) | ![points](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_points.json&label=%E2%AD%90&style=flat) | ![top](https://img.shields.io/badge/%F0%9F%8F%86-Top%20%3C1%25-10b981?style=flat) | ![contributions](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_contributions.json&label=%F0%9F%93%A6&style=flat) | ![downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_downloads.json&label=%E2%AC%87%EF%B8%8F&style=flat) | ![saves](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_saves.json&label=%F0%9F%92%BE&style=flat) | ![views](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FFu-Jie%2Fdb3d95687075a880af6f1fba76d679c6%2Fraw%2Fbadge_views.json&label=%F0%9F%91%81%EF%B8%8F&style=flat) | @@ -30,6 +30,15 @@ - **ๅ—ไฟๆŠคๅคด้ƒจ่ฟฝ่ธช**๏ผšๆ‘˜่ฆ่กŒไผš่ฎฐๅฝ•ๆœ‰ๅคšๅฐ‘ๅผ€ๅคดๆถˆๆฏๆ˜ฏๅœจๆ‘˜่ฆไน‹ๅค–ๆŒ‰ๅŽŸๆ–‡ไฟ็•™็š„ใ€‚ๅฆ‚ๆžœๅฝ“ๅ‰ `keep_first` ็ญ–็•ฅๅทฒ็ปไธๅ†ไฟ็•™่ฟ™ไบ›ๆถˆๆฏ๏ผŒ่ฏฅๆ‘˜่ฆ่กŒไธไผšไฝœไธบ branch-valid ่ฆ†็›–่Œƒๅ›ดๅค็”จใ€‚ - **ๅฎ‰ๅ…จๅ‡็บง่กŒไธบ**๏ผšๆฒกๆœ‰่ฆ†็›–่Œƒๅ›ดๅ…ƒๆ•ฐๆฎ็š„ legacy summary ไธๅ†่ขซๅฝ“ๆˆๅฏไฟก่ฆ†็›–ใ€‚ๅ‡็บงๅŽ็š„็ฌฌไธ€่ฝฎๅฏน่ฏๅฏ่ƒฝไผšๅ‘้€ๆ›ดๅคšๅŽŸๅง‹ไธŠไธ‹ๆ–‡๏ผŒ็›ดๅˆฐ็”Ÿๆˆ branch-valid ๆ‘˜่ฆ่กŒใ€‚ +## 1.7.3 ็‰ˆๆœฌๆ›ดๆ–ฐ + +- **ๆ–ฐ PostgreSQL ไธŠ็š„ summary ๆŒไน…ๅŒ–**๏ผšไฟฎๅคไบ† `DuplicateTable: relation "ix_chat_summary_chat_id" already exists` ไปฅๅŠ้šไน‹ๅ‡บ็Žฐ็š„ `โš ๏ธ Summary generated but was not persisted`ใ€‚`CREATE TABLE` ไน‹ๅ‰ไผšๅ…ˆๅฏนๅ…ฑไบซ็š„ SQLAlchemy metadata ๅš็ดขๅผ•ๅŽป้‡๏ผŒๅนถๅน‚็ญ‰ๆธ…็†ๆ’žๅ็š„ legacy ็ดขๅผ•ใ€‚ +- **ๆ—  id ๆ™ฎ้€šๅฏน่ฏๅˆ†ๆ”ฏ็š„ outlet summary ๅค็”จ**๏ผšๅฝ“ outlet ่ฏทๆฑ‚ๅธฆ `chat_id` ไฝ†ๆฒกๆœ‰็จณๅฎš message refs ๆ—ถ๏ผŒๆ’ไปถ็Žฐๅœจไผš่ฏปๅ–ๆ•ฐๆฎๅบ“้‡Œ็š„ active branch ๅนถๅฏน้ฝ body๏ผŒไฝฟ็”Ÿๆˆ็š„ summary ๅฏไปฅๆŒไน…ๅŒ–ๅนถๅœจๅŽ็ปญ่ฝฎๆฌกๅค็”จใ€‚ +- **Reasoning model ็š„ inlet ๅค็”จ๏ผˆissue #98๏ผ‰**๏ผšReasoning model ๅœจๆ•ฐๆฎๅบ“้‡Œไฟๅญ˜ๅธฆๆŠ˜ๅ  `
` ็š„ content๏ผŒไฝ† `process_messages_with_output` ้‡ๅปบ่ฏทๆฑ‚ body ๆ—ถไผšๅ‰ฅ็ฆปๆˆ–ๆ”นๅ†™ reasoning๏ผŒๅฏผ่‡ด็ผ“ๅญ˜็š„ summary ๆฏ่ฝฎ้ƒฝ่ขซๆ‹’็ปใ€‚ๆ–ฐๅขž็š„ position-based ๅ…œๅบ•่ทฏๅพ„๏ผˆPath 3๏ผ‰ๅœจ body ไธŽ DB ๅˆ†ๆ”ฏ้•ฟๅบฆ็›ธๅŒใ€role / tool_calls / tool_call_id ๆŒ‰ไฝ็ฝฎๅŒน้…ๆ—ถๆŽฅๅ— snapshotใ€‚ๅธฆ `output` ๆ•ฐ็ป„็š„ DB ๆถˆๆฏ่ฑๅ… content ๆฏ”ๅฏน๏ผ›ๆฒกๆœ‰ `output` ็š„ DB ๆถˆๆฏไป่ฆๆฑ‚ content ็ฒพ็กฎๅŒน้…๏ผŒๅ› ๆญค่ขซ็ผ–่พ‘ๆˆ–็ฏกๆ”น็š„ body ไผš่ขซๆ‹’็ปใ€‚ +- **Path 3 ๆททๅˆ id ไฟฎๅค**๏ผš`process_messages_with_output` ๅชๅ‰ฅ็ฆป `output`๏ผˆไธๅ‰ฅ็ฆป `id`๏ผ‰๏ผŒ็œŸๅฎž reasoning ๅฏน่ฏ็š„ body ๆ˜ฏๆททๅˆ id ็š„โ€”โ€”all-idless ๅฎˆๅซๅทฒ็งป้™ค๏ผŒPath 3 ็Žฐๅœจ่ƒฝๆŽฅๅ—็œŸๅฎž reasoning ๅฏน่ฏใ€‚ +- **Path 3 ่ฏŠๆ–ญๆ—ฅๅฟ—**๏ผšๅฝ“ Path 3 ๆปก่ถณๆกไปถไฝ†ๆŸไธชไฝ็ฝฎๆฃ€ๆŸฅๅคฑ่ดฅๆ—ถ๏ผŒ`debug_mode` ็Žฐๅœจไผš่ฎฐๅฝ•็ฌฌไธ€ไธชๅคฑ่ดฅ็š„ไฝ็ฝฎ็ดขๅผ•ๅ’ŒไธๅŒน้…็š„ๅญ—ๆฎต๏ผˆrole / tool_calls / tool_call_id / content๏ผ‰๏ผŒ่ฎฉ้™้ป˜ๆ‹’็ปๅ˜ๅพ—ๅฏ่งใ€‚ +- **็ซฏๅˆฐ็ซฏ้ชŒ่ฏ**๏ผšๆ–ฐๅขžๆต‹่ฏ•ๆจกๅ—ๅ†…่”ไบ†ไปŽ OpenWebUI main ๅˆ†ๆ”ฏ้€่กŒๅคๅˆถ็š„ `convert_output_to_messages`ใ€`process_messages_with_output` ๅ’Œ `reconcile_tool_pairs`๏ผŒๅนถๅฏน OpenAI ๅ…ผๅฎน / Ollama / llama.cpp / ๅธฆ tool_calls ็š„ reasoning ๅฏน่ฏ่ตฐๅฎŒๆ•ด `inlet()` ๅ…ฅๅฃ่ฟ›่กŒๅ›žๆ”พ้ชŒ่ฏใ€‚body ๆž„้€ ็ฒพ็กฎ้•œๅƒ็œŸๅฎž็ฎก้“๏ผˆ็œŸๆญฃ็š„ๆททๅˆ id๏ผ‰๏ผŒๅ›žๅฝ’ๆต‹่ฏ•่ฆ†็›–ๆททๅˆ id ๅฝข็Šถๅ’Œ Path 3 ๆŽฅๅ—ใ€‚ + ## 1.7.2 ็‰ˆๆœฌๆ›ดๆ–ฐ - **ๆ‘˜่ฆๆณจๅ…ฅๅฎ‰ๅ…จ่พน็•Œ**๏ผšๆณจๅ…ฅ็ป™ๆจกๅž‹็š„ summary ็Žฐๅœจไผšๆ˜Ž็กฎ่ฏดๆ˜Ž๏ผŒsummary ้‡Œ็š„็›ฎๆ ‡ใ€ๅพ…ๅŠžๅ’Œๅทฅๅ…ท็Šถๆ€ๅชไปฃ่กจๅކๅฒไธŠไธ‹ๆ–‡๏ผŒไธๆ˜ฏๆ–ฐ็š„ๆŒ‡ไปคใ€‚ @@ -262,6 +271,6 @@ flowchart TD ## ๆ›ดๆ–ฐๆ—ฅๅฟ— -่ฏทๆŸฅ็œ‹ [`v1.7.2` ็‰ˆๆœฌๅ‘ๅธƒ่ฏดๆ˜Ž](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/filters/async-context-compression/v1.7.2_CN.md) ่Žทๅ–ๆœฌๆฌก็‰ˆๆœฌ็š„็‹ฌ็ซ‹ๅ‘ๅธƒๆ‘˜่ฆใ€‚ +่ฏทๆŸฅ็œ‹ [`v1.7.3` ็‰ˆๆœฌๅ‘ๅธƒ่ฏดๆ˜Ž](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/filters/async-context-compression/v1.7.3_CN.md) ่Žทๅ–ๆœฌๆฌก็‰ˆๆœฌ็š„็‹ฌ็ซ‹ๅ‘ๅธƒๆ‘˜่ฆใ€‚ ๅฎŒๆ•ดๅކๅฒ่ฏทๆŸฅ็œ‹ GitHub ้กน็›ฎ๏ผš [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) diff --git a/plugins/filters/async-context-compression/_diag.py b/plugins/filters/async-context-compression/_diag.py deleted file mode 100644 index aeb94bb..0000000 --- a/plugins/filters/async-context-compression/_diag.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Diagnostic script: trace _load_applicable_summary_snapshot step by step.""" -import asyncio -import json -import sys -sys.path.insert(0, ".") -from test_issue98_e2e import ( - Filter, _build_reasoning_chat_openai_compatible, - _live_refs_by_id, _snapshot, -) - -filter_obj = Filter() -filter_obj.valves.keep_last = 0 -filter_obj._get_model_thresholds = lambda m: {"max_context_tokens": 100000, "compression_threshold_tokens": 1000} - -db_messages, body_messages = _build_reasoning_chat_openai_compatible() -snapshots = [_snapshot("test summary", filter_obj._message_refs_for_prefix(db_messages, 2))] - -print("=== Step 0: Data ===") -print(f"db_messages count: {len(db_messages)}") -print(f"body_messages count: {len(body_messages)}") -print(f"snapshot refs: {json.loads(snapshots[0]['covered_message_refs_json'])}") - -print("\n=== Step 1: _current_branch_refs(body_messages) ===") -body_refs = filter_obj._current_branch_refs(body_messages) -print(f"body_refs: {body_refs}") - -print("\n=== Step 2: _current_branch_refs(db_messages) ===") -db_refs = filter_obj._current_branch_refs(db_messages) -print(f"db_refs: {db_refs}") - -print("\n=== Step 3: _compatible_db_branch_for_body_ref_fallback(body, db) ===") -result = filter_obj._compatible_db_branch_for_body_ref_fallback(body_messages, db_messages) -print(f"result: compatible={result[0] is not None}, boundaries={result[1]}, ignored={result[2]}") -if result[1]: - print(f" boundaries detail: {result[1]}") - -print("\n=== Step 4: _select_applicable_summary_snapshot(snapshots, db_messages) ===") -live_refs = _live_refs_by_id(filter_obj, db_messages) -print(f"live_refs keys: {list(live_refs.keys()) if live_refs else None}") -selected = filter_obj._select_applicable_summary_snapshot( - snapshots, db_messages, live_message_refs_by_id=live_refs, -) -print(f"selected: {selected}") -if selected is None: - # Try with debug_mode on to see rejection reason - filter_obj.valves.debug_mode = True - selected2 = filter_obj._select_applicable_summary_snapshot( - snapshots, db_messages, live_message_refs_by_id=live_refs, - ) - print(f"selected (debug): {selected2}") - filter_obj.valves.debug_mode = False - -print("\n=== Step 5: _message_refs_for_prefix(db_messages, 2) ===") -refs_prefix = filter_obj._message_refs_for_prefix(db_messages, 2) -print(f"refs_prefix: {refs_prefix}") - -print("\n=== Step 6: _message_ref for each db_message ===") -for i, msg in enumerate(db_messages): - ref = filter_obj._message_ref(msg) - print(f" db_message[{i}]: id={msg.get('id')}, ref={ref}") diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 74daf8e..e1b7b05 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -5,7 +5,7 @@ author_url: https://github.com/Fu-Jie/openwebui-extensions funding_url: https://github.com/open-webui description: Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression. -version: 1.7.2 +version: 1.7.3 openwebui_id: b1655bc8-6de9-4cad-8cb5-a6f7829a02ce license: MIT @@ -1157,6 +1157,47 @@ def _body_position_matches_db_message( return True + def _first_body_position_mismatch( + self, + body_messages: List[Dict[str, Any]], + db_messages: List[Dict[str, Any]], + ) -> Optional[tuple]: + """Return (index, reason) of the first position mismatch, or None. + + Mirrors :meth:`_body_position_matches_db_message` but returns a + human-readable reason instead of a boolean, so the inlet can log + exactly which position and field caused Path 3 to reject the body. + """ + for index, (body_message, db_message) in enumerate( + zip(body_messages, db_messages) + ): + if self._normalize_role(body_message.get("role")) != self._normalize_role( + db_message.get("role") + ): + return index, ( + f"role mismatch (body={body_message.get('role')!r}, " + f"db={db_message.get('role')!r})" + ) + if body_message.get("tool_calls") != db_message.get("tool_calls"): + return index, "tool_calls mismatch" + if body_message.get("tool_call_id") != db_message.get("tool_call_id"): + return index, ( + f"tool_call_id mismatch " + f"(body={body_message.get('tool_call_id')!r}, " + f"db={db_message.get('tool_call_id')!r})" + ) + db_output = db_message.get("output") + has_db_output = isinstance(db_output, list) and bool(db_output) + if not has_db_output: + if body_message.get("content") != db_message.get("content"): + body_preview = str(body_message.get("content"))[:120] + db_preview = str(db_message.get("content"))[:120] + return index, ( + f"content mismatch on no-output message " + f"(body={body_preview!r}, db={db_preview!r})" + ) + return None + def _message_fingerprint(self, message: Dict[str, Any]) -> str: """Fingerprint the model-visible payload to detect in-place edits.""" payload = self._message_fingerprint_payload(message) @@ -2503,9 +2544,9 @@ def _body_to_db_coverage_map_for_ref_fallback( ): return db_to_body_boundaries - # Path 3 (position-based fallback): when the body carries no - # OpenWebUI message ids and matches the DB active branch 1:1 in - # count and role sequence, accept the DB refs by position. + # Path 3 (position-based fallback): when the body matches the DB + # active branch 1:1 in count and role / tool_calls / tool_call_id + # sequence, accept the DB refs by position. # # This covers reasoning models (and any future rebuild path) where # OpenWebUI regenerates assistant ``content`` from the ``output`` @@ -2515,6 +2556,15 @@ def _body_to_db_coverage_map_for_ref_fallback( # ``
`` block in the DB but stripped from # the body). Content-level comparison cannot succeed in that case. # + # The body need NOT be fully idless. ``process_messages_with_output`` + # only strips ``output`` (not ``id``), so user / system / no-output + # assistant messages keep their DB node ``id`` while only the + # rebuilt assistant-with-output messages become idless โ€” the request + # body is mixed-id in practice. We reach this fallback only when + # ``_current_branch_refs(messages) is None`` upstream (i.e. the body + # as a whole does not expose a usable ref sequence), so requiring + # all-idless here would wrongly reject every real reasoning chat. + # # Guards against false positives: # - ``unfolded_messages`` non-empty rules out conversion failures # (when ``_unfold_db_branch_for_body_ref_fallback`` cannot parse @@ -2526,7 +2576,6 @@ def _body_to_db_coverage_map_for_ref_fallback( # ``tool_call_id`` to match (catches tampered tool calls). if ( unfolded_messages - and all(not self._get_message_id(m) for m in body_messages) and len(body_messages) == len(db_messages) and all( self._body_position_matches_db_message(body_message, db_message) @@ -2535,6 +2584,21 @@ def _body_to_db_coverage_map_for_ref_fallback( ): return list(range(len(db_messages) + 1)) + if ( + unfolded_messages + and len(body_messages) == len(db_messages) + and self.valves.debug_mode + ): + first_mismatch = self._first_body_position_mismatch( + body_messages, db_messages + ) + if first_mismatch is not None: + mismatch_index, mismatch_reason = first_mismatch + logger.info( + "[Summary Snapshot] Path 3 position fallback rejected at " + f"index={mismatch_index}: {mismatch_reason}" + ) + return None def _compatible_db_branch_for_body_ref_fallback( diff --git a/plugins/filters/async-context-compression/test_issue98_e2e.py b/plugins/filters/async-context-compression/test_issue98_e2e.py index f654b59..2357cb9 100644 --- a/plugins/filters/async-context-compression/test_issue98_e2e.py +++ b/plugins/filters/async-context-compression/test_issue98_e2e.py @@ -186,31 +186,22 @@ def _owui_process_messages_with_output(messages, reasoning_format=None): return processed -def _strip_ids(messages): - """Simulate the OpenWebUI frontend: request body carries no message node ids. - - ``process_messages_with_output`` itself only strips ``output``; it does NOT - remove ``id``. But the request body that hits the inlet filter has no - ``id`` fields โ€” the frontend sends plain chat-completion messages. This - helper strips ids after reconstruction to match the real inlet payload. - """ - result = [] - for msg in messages: - clean = {k: v for k, v in msg.items() if k != 'id' and k != 'message_id'} - result.append(clean) - return result - - def _build_body_from_db(db_messages, reasoning_format=None): - """Simulate the full OpenWebUI inlet pipeline: + """Simulate the real OpenWebUI inlet pipeline. + + ``process_messages_with_output`` rebuilds assistant messages that carry an + ``output`` array via ``convert_output_to_messages`` (idless), but for every + other message it only strips the ``output`` key โ€” the DB node ``id`` + survives. The request body that hits the inlet filter is therefore + **mixed-id**: user / system / no-output assistant messages keep their + ``id``; only rebuilt assistant-with-output messages are idless. - 1. process_messages_with_output (rebuilds assistant content from output) - 2. strip message node ids (frontend sends plain chat-completion messages) + This mirrors the behaviour confirmed against the OpenWebUI main branch + (``clean_message = {k: v for k, v in message.items() if k != 'output'}``). """ - rebuilt = _owui_process_messages_with_output( + return _owui_process_messages_with_output( deepcopy(db_messages), reasoning_format=reasoning_format ) - return _strip_ids(rebuilt) def _owui_get_reasoning_format(model): @@ -724,16 +715,35 @@ def test_tampered_tool_calls_rejected(self): ) self.assertIsNone(coverage, "Must reject tampered tool_calls") - # โ”€โ”€ Scenario 7: Verify body has no ids (precondition for Path 3) โ”€โ”€ + # โ”€โ”€ Scenario 7: Verify body is mixed-id (real OWUI shape) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def test_openai_reasoning_body_is_mixed_id(self): + """Real OWUI body is mixed-id: rebuilt assistant-with-output messages + are idless, but user / no-output messages keep their DB node id. - def test_openai_reasoning_body_has_no_message_ids(self): - """Verify that process_messages_with_output strips ids from body.""" + ``process_messages_with_output`` only strips ``output``, not ``id``. + """ db_messages, body_messages = _build_reasoning_chat_openai_compatible() - for msg in body_messages: - self.assertIsNone( - self.filter._get_message_id(msg), - "Body messages must not carry OpenWebUI ids (plain chat)", - ) + # assistant-with-output message (index 1) is rebuilt โ†’ idless + self.assertIsNone(self.filter._get_message_id(body_messages[1])) + # user messages (index 0, 2) keep their DB id + self.assertIsNotNone(self.filter._get_message_id(body_messages[0])) + self.assertIsNotNone(self.filter._get_message_id(body_messages[2])) + + def test_mixed_id_body_path3_accepts(self): + """Path 3 must accept a mixed-id body (the real OWUI shape). + + Regression for the all-idless guard that wrongly rejected real + reasoning chats where user messages keep their id. + """ + db_messages, body_messages = _build_reasoning_chat_openai_compatible() + # Confirm the body is genuinely mixed-id before asserting Path 3. + has_id = [bool(self.filter._get_message_id(m)) for m in body_messages] + self.assertEqual(has_id, [True, False, True]) + coverage = self.filter._body_to_db_coverage_map_for_ref_fallback( + body_messages, db_messages + ) + self.assertEqual(coverage, [0, 1, 2, 3]) # โ”€โ”€ Scenario 8: Full inlet for Ollama reasoning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/plugins/filters/async-context-compression/v1.7.3.md b/plugins/filters/async-context-compression/v1.7.3.md new file mode 100644 index 0000000..ab3ad4a --- /dev/null +++ b/plugins/filters/async-context-compression/v1.7.3.md @@ -0,0 +1,27 @@ +# Async Context Compression v1.7.3 Release Notes + +## Overview + +This patch release fixes two regressions reported by community users: summary persistence silently failing on fresh PostgreSQL databases, and cached summaries being rejected every turn for reasoning models (issue #98). It also ships an end-to-end verification suite that replays real OpenWebUI request-construction code against the filter, plus diagnostic logging so any future per-position rejection can be pinpointed without code changes. + +## Fixes + +- **Summary persistence on fresh databases**: The `chat_summary` table failed to initialize on some PostgreSQL deployments because the shared SQLAlchemy metadata retained a stale unique index definition with the same name as the new non-unique index. `_init_database` now deduplicates `ChatSummary` table index definitions in memory before emitting `CREATE TABLE`, and clears any legacy colliding indexes (`ix_chat_summary_chat_id`, `ix_chat_summary_branch_tip_id`, `ix_chat_summary_updated_at`) idempotently. This resolves `DuplicateTable: relation "ix_chat_summary_chat_id" already exists` and the resulting `โš ๏ธ Summary generated but was not persisted`. +- **Outlet summary reuse for idless plain-chat branches**: When the outlet request had no stable message refs but carried a `chat_id`, the filter previously failed to attach refs to the generated summary. It now reads the active DB branch and aligns the body against it via `_compatible_db_branch_for_body_ref_fallback`, so plain-chat summaries can be persisted and reused on subsequent turns. +- **Reasoning-model inlet rejection (issue #98)**: Reasoning models store assistant content with folded `
` blocks in the DB, but the request body reconstructed by `process_messages_with_output` strips or re-tags the reasoning. Both existing alignment paths (folded content match and unfolded content match) therefore failed, and the inlet re-sent the full uncompressed history every turn. A new position-based fallback (Path 3) accepts the snapshot when the body and DB branch have equal length, roles / tool_calls / tool_call_id match position-by-position, and DB messages that carry an `output` array are exempted from content comparison. DB messages without an `output` array still require exact content equality, so edited or tampered bodies are rejected. Tool-call expansion (body count > DB count) is rejected because the position map no longer aligns. +- **Path 3 mixed-id fix (issue #98 follow-up)**: The initial Path 3 guard wrongly required every body message to be idless, but `process_messages_with_output` only strips the `output` key โ€” not `id`. Real reasoning-chat request bodies are therefore **mixed-id**: user / system / no-output assistant messages keep their DB node `id`, while only rebuilt assistant-with-output messages are idless. The all-idless guard rejected every real body before the position check could run. The guard has been removed โ€” reaching Path 3 already requires `_current_branch_refs(messages) is None` upstream (the body as a whole exposes no usable ref sequence), so the check was both wrong and redundant. +- **Path 3 diagnostic logging**: When Path 3 is eligible (equal length, unfoldable output) but a per-position `_body_position_matches_db_message` check fails, the filter now logs the first failing index and the specific field that mismatched (role / tool_calls / tool_call_id / content-on-no-output-message). This makes silent rejections observable in `debug_mode` without code instrumentation. +- **Fail-closed boundaries preserved**: Path 3 only activates when the existing folded and unfolded paths both fail. Tampered `tool_calls`, edited content on DB messages without `output`, mismatched `tool_call_id`, role drift, and count mismatches all continue to reject the snapshot. + +## Verification + +- A new end-to-end test module inlines `convert_output_to_messages`, `process_messages_with_output`, and `reconcile_tool_pairs` copied verbatim from the OpenWebUI main branch, then reconstructs the request body for OpenAI-compatible (`reasoning_format=None`), Ollama (`think_tags`), llama.cpp (`reasoning_content`), and tool-call reasoning chats. The body builder mirrors the real OpenWebUI pipeline exactly (no artificial id stripping), producing genuinely mixed-id bodies. Each scenario asserts behavior at the alignment-method level and at the full `inlet()` entry point. +- The position-based fallback is validated for both acceptance (reasoning content mismatch with DB output present; mixed-id body) and rejection (edited content with no DB output, tampered tool_calls, tool-call count expansion). +- Regression tests `test_openai_reasoning_body_is_mixed_id` and `test_mixed_id_body_path3_accepts` lock in the real mixed-id shape and Path 3 acceptance. +- Full suite: 15 end-to-end tests plus the existing unit-test suite remain green (one environment-only failure due to `sqlalchemy` not being installed in CI is unrelated to this change). + +## Upgrade Notes + +No database migration is required. Update or reinstall the filter so OpenWebUI's stored function content includes the v1.7.3 metadata-dedup and reasoning-model inlet fixes. On first launch after upgrade, the database initializer will clean any colliding legacy indexes automatically. + +If you tested an earlier build and saw `DB active branch fallback skipped: request body is not a compatible idless view`, this release resolves it. If Path 3 still rejects, enable `debug_mode` and look for `Path 3 position fallback rejected at index=N: ` โ€” please share that line in issue #98. diff --git a/plugins/filters/async-context-compression/v1.7.3_CN.md b/plugins/filters/async-context-compression/v1.7.3_CN.md new file mode 100644 index 0000000..e5d0e1a --- /dev/null +++ b/plugins/filters/async-context-compression/v1.7.3_CN.md @@ -0,0 +1,27 @@ +# ๅผ‚ๆญฅไธŠไธ‹ๆ–‡ๅŽ‹็ผฉ v1.7.3 ็‰ˆๆœฌๅ‘ๅธƒ่ฏดๆ˜Ž + +## ๆฆ‚่ฟฐ + +ๆœฌ่กฅไธ็‰ˆๆœฌไฟฎๅคไบ†็คพๅŒบ็”จๆˆทๅ้ฆˆ็š„ไธคไธชๅ›žๅฝ’้—ฎ้ข˜๏ผšๅœจๆ–ฐ้ƒจ็ฝฒ็š„ PostgreSQL ไธŠ summary ๆŒไน…ๅŒ–้™้ป˜ๅคฑ่ดฅ๏ผŒไปฅๅŠ reasoning model ๅœบๆ™ฏไธ‹็ผ“ๅญ˜็š„ summary ๆฏ่ฝฎ้ƒฝ่ขซๆ‹’็ป๏ผˆissue #98๏ผ‰ใ€‚ๅŒๆ—ถๆ–ฐๅขžไบ†ไธ€ๅฅ—็ซฏๅˆฐ็ซฏ้ชŒ่ฏๆต‹่ฏ•๏ผŒ็”จ OpenWebUI ็œŸๅฎž็š„่ฏทๆฑ‚ๆž„้€ ไปฃ็ ๅฏน่ฟ‡ๆปคๅ™จ่ฟ›่กŒ้ชŒ่ฏ๏ผŒๅนถๅขžๅŠ ไบ†่ฏŠๆ–ญๆ—ฅๅฟ—๏ผŒๆœชๆฅไปปไฝ•ๆŒ‰ไฝ็ฝฎๆ‹’็ป้ƒฝ่ƒฝๅœจไธๆ”นไปฃ็ ็š„ๆƒ…ๅ†ตไธ‹ๅฎšไฝใ€‚ + +## ไฟฎๅคๅ†…ๅฎน + +- **ๆ–ฐๆ•ฐๆฎๅบ“ไธŠ็š„ summary ๆŒไน…ๅŒ–**๏ผš้ƒจๅˆ† PostgreSQL ็Žฏๅขƒไธ‹ `chat_summary` ่กจๅˆๅง‹ๅŒ–ๅคฑ่ดฅ๏ผŒๆ นๅ› ๆ˜ฏๅ…ฑไบซ็š„ SQLAlchemy metadata ไธญๆฎ‹็•™ไบ†ไธŽๆ–ฐ็‰ˆ้žๅ”ฏไธ€็ดขๅผ•ๅŒๅ็š„ๆ—ง็‰ˆๅ”ฏไธ€็ดขๅผ•ๅฎšไน‰ใ€‚`_init_database` ็Žฐๅœจไผšๅœจๆ‰ง่กŒ `CREATE TABLE` ไน‹ๅ‰ๅ…ˆๅœจๅ†…ๅญ˜้‡Œๅฏน `ChatSummary` ่กจ็š„็ดขๅผ•ๅฎšไน‰ๅŽป้‡๏ผŒๅนถๅน‚็ญ‰ๆธ…็†ๅฏ่ƒฝๆ’žๅ็š„ legacy ็ดขๅผ•๏ผˆ`ix_chat_summary_chat_id`ใ€`ix_chat_summary_branch_tip_id`ใ€`ix_chat_summary_updated_at`๏ผ‰ใ€‚่ฟ™่งฃๅ†ณไบ† `DuplicateTable: relation "ix_chat_summary_chat_id" already exists` ไปฅๅŠ้šไน‹ๅ‡บ็Žฐ็š„ `โš ๏ธ Summary generated but was not persisted`ใ€‚ +- **ๆ—  id ๆ™ฎ้€šๅฏน่ฏๅˆ†ๆ”ฏ็š„ outlet summary ๅค็”จ**๏ผšๅฝ“ outlet ่ฏทๆฑ‚ๆฒกๆœ‰็จณๅฎš message refs ไฝ†ๅธฆๆœ‰ `chat_id` ๆ—ถ๏ผŒไน‹ๅ‰ๆ— ๆณ•ไธบ็”Ÿๆˆ็š„ summary ้™„ไธŠ refsใ€‚็Žฐๅœจไผš่ฏปๅ–ๆ•ฐๆฎๅบ“้‡Œ็š„ active branch๏ผŒๅนถ้€š่ฟ‡ `_compatible_db_branch_for_body_ref_fallback` ๅฏน้ฝ body๏ผŒๆ™ฎ้€šๅฏน่ฏ็š„ summary ไนŸ่ƒฝๆŒไน…ๅŒ–ๅนถๅœจๅŽ็ปญ่ฝฎๆฌกๅค็”จใ€‚ +- **Reasoning model ็š„ inlet ๆ‹’็ป้—ฎ้ข˜๏ผˆissue #98๏ผ‰**๏ผšReasoning model ๅœจๆ•ฐๆฎๅบ“้‡Œไฟๅญ˜็š„ assistant content ๅธฆๆŠ˜ๅ ็š„ `
` ๅ—๏ผŒไฝ† `process_messages_with_output` ้‡ๅปบ่ฏทๆฑ‚ body ๆ—ถไผšๆŠŠ reasoning ๅ‰ฅ็ฆปๆˆ–ๆ”นๅ†™ไธบๅ…ถไป–ๆ ‡็ญพใ€‚็Žฐๆœ‰็š„ไธคๆกๅฏน้ฝ่ทฏๅพ„๏ผˆfolded content ๅŒน้…ๅ’Œ unfolded content ๅŒน้…๏ผ‰ๅ› ๆญค้ƒฝไผšๅคฑ่ดฅ๏ผŒinlet ๆฏ่ฝฎ้ƒฝ้‡ๆ–ฐๅ‘้€ๅฎŒๆ•ดๆœชๅŽ‹็ผฉๅކๅฒใ€‚ๆ–ฐๅขž็š„ position-based ๅ…œๅบ•่ทฏๅพ„๏ผˆPath 3๏ผ‰ๅœจ body ไธŽ DB ๅˆ†ๆ”ฏ้•ฟๅบฆ็›ธๅŒใ€role / `tool_calls` / `tool_call_id` ๆŒ‰ไฝ็ฝฎๅŒน้…๏ผŒๅนถไธ”ๅฏนๅธฆ `output` ๆ•ฐ็ป„็š„ DB ๆถˆๆฏ่ฑๅ… content ๆฏ”ๅฏนๆ—ถๆŽฅๅ— snapshotใ€‚ๆฒกๆœ‰ `output` ๆ•ฐ็ป„็š„ DB ๆถˆๆฏไป่ฆๆฑ‚ content ็ฒพ็กฎๅŒน้…๏ผŒๅ› ๆญค่ขซ็ผ–่พ‘ๆˆ–็ฏกๆ”น็š„ body ไผš่ขซๆ‹’็ปใ€‚tool ๅฑ•ๅผ€ๅฏผ่‡ด body ๆ•ฐ้‡ๅคงไบŽ DB ๆ•ฐ้‡็š„ๆƒ…ๅ†ตไนŸไผš่ขซๆ‹’็ป๏ผŒๅ› ไธบไฝ็ฝฎๆ˜ ๅฐ„ไธๅ†ๅฏน้ฝใ€‚ +- **Path 3 ๆททๅˆ id ไฟฎๅค๏ผˆissue #98 ่ทŸ่ฟ›๏ผ‰**๏ผšPath 3 ๆœ€ๅˆ็š„ๅฎˆๅซ้”™่ฏฏๅœฐ่ฆๆฑ‚ๆ‰€ๆœ‰ body ๆถˆๆฏ้ƒฝๆฒกๆœ‰ id๏ผŒไฝ† `process_messages_with_output` ๅชๅ‰ฅ็ฆป `output` ้”ฎ๏ผŒ**ไธ**ๅ‰ฅ็ฆป `id`ใ€‚็œŸๅฎž reasoning ๅฏน่ฏ็š„่ฏทๆฑ‚ body ๅ› ๆญคๆ˜ฏ**ๆททๅˆ id** ็š„๏ผšuser / system / ๆ—  output ็š„ assistant ๆถˆๆฏไฟ็•™ DB ่Š‚็‚น `id`๏ผŒๅชๆœ‰่ขซ้‡ๅปบ็š„ๅธฆ output ็š„ assistant ๆถˆๆฏๆ‰ๆ˜ฏๆ—  id ็š„ใ€‚่ฏฅๅฎˆๅซๅœจไฝ็ฝฎๆฃ€ๆŸฅ่ฟ่กŒไน‹ๅ‰ๅฐฑๆ‹’็ปไบ†ๆ‰€ๆœ‰็œŸๅฎž bodyใ€‚ๅฎˆๅซๅทฒ็งป้™คโ€”โ€”่ƒฝ่ตฐๅˆฐ Path 3 ๆœฌ่บซๅฐฑ่ฆๆฑ‚ไธŠๆธธ `_current_branch_refs(messages) is None`๏ผˆbody ๆ•ดไฝ“ไธๆšด้œฒๅฏ็”จ็š„ ref ๅบๅˆ—๏ผ‰๏ผŒๆ‰€ไปฅ่ฏฅๆฃ€ๆŸฅๆ—ข้”™่ฏฏๅˆๅคšไฝ™ใ€‚ +- **Path 3 ่ฏŠๆ–ญๆ—ฅๅฟ—**๏ผšๅฝ“ Path 3 ๆปก่ถณๆกไปถ๏ผˆ้•ฟๅบฆ็›ธ็ญ‰ใ€output ๅฏๅฑ•ๅผ€๏ผ‰ไฝ†ๆŸไธชไฝ็ฝฎ็š„ `_body_position_matches_db_message` ๆฃ€ๆŸฅๅคฑ่ดฅๆ—ถ๏ผŒ่ฟ‡ๆปคๅ™จ็Žฐๅœจไผš่ฎฐๅฝ•็ฌฌไธ€ไธชๅคฑ่ดฅ็š„ไฝ็ฝฎ็ดขๅผ•ๅ’Œๅ…ทไฝ“ไธๅŒน้…็š„ๅญ—ๆฎต๏ผˆrole / tool_calls / tool_call_id / ๆ—  output ๆถˆๆฏ็š„ content๏ผ‰ใ€‚่ฟ™่ฎฉ `debug_mode` ไธ‹ๅŽŸๆœฌ้™้ป˜็š„ๆ‹’็ปๅ˜ๅพ—ๅฏ่ง๏ผŒๆ— ้œ€ๆ”นไปฃ็ ๆ’ๆกฉใ€‚ +- **fail-closed ่พน็•Œไฟ็•™**๏ผšPath 3 ๅชๅœจ folded ๅ’Œ unfolded ไธคๆก่ทฏๅพ„้ƒฝๅคฑ่ดฅๆ—ถๆ‰็”Ÿๆ•ˆใ€‚็ฏกๆ”น `tool_calls`ใ€ๅฏนๆ—  `output` ็š„ DB ๆถˆๆฏ็ผ–่พ‘ contentใ€`tool_call_id` ไธๅŒน้…ใ€role ๆผ‚็งปใ€ๆ•ฐ้‡ไธๅŒน้…็ญ‰ๆƒ…ๅ†ตไป็„ถไผšๆ‹’็ป snapshotใ€‚ + +## ้ชŒ่ฏ + +- ๆ–ฐๅขž็š„็ซฏๅˆฐ็ซฏๆต‹่ฏ•ๆจกๅ—ๅ†…่”ไบ†ไปŽ OpenWebUI main ๅˆ†ๆ”ฏ้€่กŒๅคๅˆถ็š„ `convert_output_to_messages`ใ€`process_messages_with_output` ๅ’Œ `reconcile_tool_pairs`๏ผŒๅนถๅŸบไบŽ OpenAI ๅ…ผๅฎน๏ผˆ`reasoning_format=None`๏ผ‰ใ€Ollama๏ผˆ`think_tags`๏ผ‰ใ€llama.cpp๏ผˆ`reasoning_content`๏ผ‰ๅ’Œๅธฆ tool_calls ็š„ reasoning ๅฏน่ฏ้‡ๅปบ่ฏทๆฑ‚ bodyใ€‚body ๆž„้€ ็ฒพ็กฎ้•œๅƒ็œŸๅฎž OpenWebUI ็ฎก้“๏ผˆไธไบบไธบๅ‰ฅ็ฆป id๏ผ‰๏ผŒไบง็”Ÿ็œŸๆญฃ็š„ๆททๅˆ id bodyใ€‚ๆฏไธชๅœบๆ™ฏ้ƒฝๅœจๅฏน้ฝๆ–นๆณ•ๅฑ‚ๅ’ŒๅฎŒๆ•ด `inlet()` ๅ…ฅๅฃๅฑ‚ๅˆ†ๅˆซๆ–ญ่จ€่กŒไธบใ€‚ +- position-based ๅ…œๅบ•่ทฏๅพ„ๅŒๆ—ถ้ชŒ่ฏไบ†ๆŽฅๅ—๏ผˆDB ๆœ‰ outputใ€content ๅ› ้‡ๅปบไธๅŒ๏ผ›ๆททๅˆ id body๏ผ‰ๅ’Œๆ‹’็ป๏ผˆDB ๆ—  output ไธ” content ่ขซ็ผ–่พ‘ใ€tool_calls ่ขซ็ฏกๆ”นใ€tool ๅฑ•ๅผ€ๅฏผ่‡ดๆ•ฐ้‡ไธ็ญ‰๏ผ‰ไธค็ฑปๆƒ…ๅฝขใ€‚ +- ๅ›žๅฝ’ๆต‹่ฏ• `test_openai_reasoning_body_is_mixed_id` ๅ’Œ `test_mixed_id_body_path3_accepts` ้”ๅฎš็œŸๅฎžๆททๅˆ id ๅฝข็Šถๅ’Œ Path 3 ๆŽฅๅ—ใ€‚ +- ๅฎŒๆ•ดๆต‹่ฏ•ๅฅ—ไปถ๏ผš15 ไธช็ซฏๅˆฐ็ซฏๆต‹่ฏ•ๅŠ ็Žฐๆœ‰ๅ•ๅ…ƒๆต‹่ฏ•ๅฅ—ไปถไฟๆŒ็ปฟ่‰ฒ๏ผˆCI ็Žฏๅขƒๅ› ๆœชๅฎ‰่ฃ… `sqlalchemy` ๅฏผ่‡ด็š„ 1 ไธชๅคฑ่ดฅไธŽๆœฌๆฌกๆ”นๅŠจๆ— ๅ…ณ๏ผ‰ใ€‚ + +## ๅ‡็บง่ฏดๆ˜Ž + +ๆœฌ็‰ˆๆœฌไธ้œ€่ฆๆ•ฐๆฎๅบ“่ฟ็งปใ€‚่ฏทๆ›ดๆ–ฐๆˆ–้‡ๆ–ฐๅฎ‰่ฃ…่ฟ‡ๆปคๅ™จ๏ผŒ็กฎไฟ OpenWebUI ไธญไฟๅญ˜็š„ function ๅ†…ๅฎนๅŒ…ๅซ v1.7.3 ็š„ metadata ๅŽป้‡ๅ’Œ reasoning model inlet ไฟฎๅคใ€‚ๅ‡็บงๅŽ้ฆ–ๆฌกๅฏๅŠจๆ—ถ๏ผŒๆ•ฐๆฎๅบ“ๅˆๅง‹ๅŒ–ๅ™จไผš่‡ชๅŠจๆธ…็†ๅฏ่ƒฝๆ’žๅ็š„ legacy ็ดขๅผ•ใ€‚ + +ๅฆ‚ๆžœไฝ ๆต‹่ฏ•่ฟ‡ๆ—ฉๆœŸๆž„ๅปบๅนถ็œ‹ๅˆฐ `DB active branch fallback skipped: request body is not a compatible idless view`๏ผŒๆœฌ็‰ˆๆœฌๅทฒ่งฃๅ†ณใ€‚ๅฆ‚ๆžœ Path 3 ไป็„ถๆ‹’็ป๏ผŒ่ฏทๅผ€ๅฏ `debug_mode` ๅนถๆŸฅๆ‰พ `Path 3 position fallback rejected at index=N: `๏ผŒๆŠŠ่ฟ™ไธ€่กŒๅ›žๅคๅˆฐ issue #98ใ€‚ From 9a3689aa84a2735547a2a9add49637bdfa38df6e Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 15:22:15 +0000 Subject: [PATCH 10/16] fix(inlet): walk DB branch from user_message_id to match OpenWebUI body (issue #98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenWebUI builds the inlet request body by walking the parentId chain from metadata['user_message_id'] (load_messages_from_db in utils/middleware.py). The filter's _load_full_chat_messages instead walked from history['currentId'], which after a regeneration or edit points at a DIFFERENT branch tip than the one the body was reconstructed from. This caused the DB-rebuilt branch to diverge mid-chain from the actual request body, making the snapshot fail branch-validity checks and silently drop the saved summary. Root cause (diagnosed by @Tuxie in issue #98): - currentId = tip of the currently-displayed branch (may contain an in-progress assistant placeholder, or a regenerated sibling) - user_message_id = the user message that triggered THIS request After regeneration/edit these anchor two different branches; walking from currentId reconstructs a branch that does not match the body's branch. Fix: - _load_full_chat_messages gains an anchor_message_id parameter; when provided (and present in history.messages) it is used as the walk anchor instead of currentId. - _load_applicable_summary_snapshot threads anchor_message_id through. - inlet() extracts __metadata__['user_message_id'] and passes it as the anchor, so the DB reconstruction follows the SAME branch the body came from. - outlet() and non-inlet callers keep the currentId fallback, since at outlet time the just-completed assistant message is the tip and SHOULD be included. Tests (test_issue98_e2e.py): - _build_branch_fork_history: builds a chat with a regeneration fork where currentId ("a1prime") and user_message_id ("u2") sit on different branches; assistant messages carry output arrays so the body is mixed-id. - test_load_full_chat_walks_from_anchor_when_provided: with anchor the walk follows the original branch; without it falls back to currentId. - test_branch_divergence_inlet_injects_summary: full inlet call where the body is built from user_message_id's branch but currentId points elsewhere โ€” verifies the summary is still injected. - test_branch_divergence_without_anchor_fails: same fork but no user_message_id in metadata โ†’ anchor is None โ†’ walk follows currentId โ†’ branch mismatch โ†’ snapshot rejected. Proves the anchor is load-bearing. Mock signatures in test_async_context_compression.py updated to accept **kwargs so the new anchor_message_id parameter flows through the existing fake_load_snapshot / fake_load_applicable_summary_snapshot stubs. --- .../async_context_compression.py | 45 +++- .../test_async_context_compression.py | 42 ++- .../test_issue98_e2e.py | 250 +++++++++++++++++- 3 files changed, 316 insertions(+), 21 deletions(-) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index e1b7b05..3a4abad 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -1845,8 +1845,24 @@ def _filter_model_visible_history_messages( if not self._is_failed_assistant_message(message) ] - async def _load_full_chat_messages(self, chat_id: str) -> List[Dict[str, Any]]: - """Load the full persisted chat history for summary decisions when available.""" + async def _load_full_chat_messages( + self, + chat_id: str, + anchor_message_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Load the full persisted chat history for summary decisions when available. + + OpenWebUI builds the inlet request body by walking the ``parentId`` + chain from ``metadata['user_message_id']`` (see + ``load_messages_from_db`` in ``utils/middleware.py``). ``currentId`` + instead points at the tip of the currently-displayed branch โ€” which, + after a regeneration or edit, can be a *different* branch than the one + the request body walks. When ``anchor_message_id`` (the + ``user_message_id`` from the inlet metadata) is available, walk from + it so our DB reconstruction matches OpenWebUI's body reconstruction. + Fall back to ``currentId`` for outlet / non-inlet callers where the + just-completed assistant message should be included. + """ if not chat_id or Chats is None: return [] @@ -1864,9 +1880,13 @@ async def _load_full_chat_messages(self, chat_id: str) -> List[Dict[str, Any]]: if isinstance(history, dict): history_messages = history.get("messages") if isinstance(history_messages, dict) and history_messages: - current_id = history.get("currentId") or history.get("current_id") + walk_anchor = None + if isinstance(anchor_message_id, str) and anchor_message_id in history_messages: + walk_anchor = anchor_message_id + if not walk_anchor: + walk_anchor = history.get("currentId") or history.get("current_id") branch_messages = self._reconstruct_active_history_branch( - history_messages, current_id + history_messages, walk_anchor ) if branch_messages: return self._filter_model_visible_history_messages( @@ -4476,6 +4496,7 @@ async def _load_applicable_summary_snapshot( live_message_refs_by_id: Optional[Dict[str, Dict[str, str]]] = None, max_coverage_count: Optional[int] = None, enforce_keep_first: bool = True, + anchor_message_id: Optional[str] = None, ) -> Optional[ChatSummary]: snapshots = await self._load_summary_snapshots(chat_id) if not snapshots: @@ -4496,7 +4517,9 @@ async def _load_applicable_summary_snapshot( if require_full_coverage or self._current_branch_refs(messages) is not None: return None - db_messages = await self._load_full_chat_messages(chat_id) + db_messages = await self._load_full_chat_messages( + chat_id, anchor_message_id=anchor_message_id + ) ( compatible_db_messages, db_to_body_boundaries, @@ -5719,9 +5742,21 @@ async def inlet( # Load only branch-valid summary rows. Legacy count-only chat_summary # rows are rebuilt during database initialization and are never trusted # as coverage proof. + # + # OpenWebUI builds the inlet body by walking the parentId chain from + # ``metadata['user_message_id']`` (load_messages_from_db). Pass it as + # the DB walk anchor so our reconstruction follows the SAME branch the + # body came from โ€” ``history['currentId']`` can point at a different + # (regenerated/edited) branch and cause a mid-chain role divergence. + inlet_user_message_id = ( + __metadata__.get("user_message_id") + if isinstance(__metadata__, dict) + else None + ) summary_snapshot = await self._load_applicable_summary_snapshot( chat_id, messages, + anchor_message_id=inlet_user_message_id, ) # Calculate effective_keep_first to ensure all system messages are protected diff --git a/plugins/filters/async-context-compression/test_async_context_compression.py b/plugins/filters/async-context-compression/test_async_context_compression.py index db3e3e4..f7b10f5 100644 --- a/plugins/filters/async-context-compression/test_async_context_compression.py +++ b/plugins/filters/async-context-compression/test_async_context_compression.py @@ -313,7 +313,7 @@ def add(self, row): self.rows.append(row) return row - async def load(self, chat_id, messages, require_full_coverage=False): + async def load(self, chat_id, messages, require_full_coverage=False, **kwargs): return self.filter._select_applicable_summary_snapshot( list(self.rows), messages, @@ -1092,6 +1092,7 @@ async def fake_load_snapshot( chat_id, messages, require_full_coverage=False, + **kwargs, ): return self.filter._select_applicable_summary_snapshot( snapshots, @@ -1157,6 +1158,7 @@ async def fake_load_snapshot( chat_id, messages, require_full_coverage=False, + **kwargs, ): return self.filter._select_applicable_summary_snapshot( snapshots, @@ -1559,6 +1561,7 @@ async def fake_load_snapshot( chat_id, messages, require_full_coverage=False, + **kwargs, ): return self.filter._select_applicable_summary_snapshot( snapshots, @@ -1619,6 +1622,7 @@ async def fake_load_snapshot( chat_id, messages, require_full_coverage=False, + **kwargs, ): return self.filter._select_applicable_summary_snapshot( snapshots, @@ -1716,7 +1720,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -1783,7 +1787,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -1870,7 +1874,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -1944,7 +1948,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -2022,7 +2026,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -2184,7 +2188,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -2322,7 +2326,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -2381,6 +2385,7 @@ async def fake_load_snapshot( chat_id, messages, require_full_coverage=False, + **kwargs, ): return self.filter._select_applicable_summary_snapshot( snapshots, @@ -2458,6 +2463,7 @@ async def fake_load_snapshot( chat_id, messages, require_full_coverage=False, + **kwargs, ): return self.filter._select_applicable_summary_snapshot( snapshots, @@ -3762,7 +3768,7 @@ async def fake_user_context(__user__, __event_call__): async def noop_log(*args, **kwargs): return None - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return [] def fake_locked_summary_task( @@ -4405,7 +4411,7 @@ async def noop_save_summary(*args, **kwargs): ) ) self.filter._count_tokens = lambda text: len(text) - async def fake_load_applicable_summary_snapshot(chat_id, messages): + async def fake_load_applicable_summary_snapshot(chat_id, messages, **kwargs): return types.SimpleNamespace(summary="P" * 300) self.filter._load_applicable_summary_snapshot = ( @@ -4453,7 +4459,7 @@ def test_generate_summary_async_db_previous_summary_starts_after_previous_covera previous_snapshot = _snapshot("previous branch summary", previous_refs) captured = {} - async def fake_load_applicable_summary_snapshot(chat_id, loaded_messages): + async def fake_load_applicable_summary_snapshot(chat_id, loaded_messages, **kwargs): self.assertEqual(loaded_messages, messages) return previous_snapshot @@ -5636,6 +5642,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): self.assertIn(require_full_coverage, (True, False)) if require_full_coverage: @@ -5717,6 +5724,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): self.assertTrue(require_full_coverage) return self.filter._select_applicable_summary_snapshot( @@ -5848,6 +5856,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): self.assertEqual( [message["id"] for message in messages], @@ -5923,6 +5932,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): if require_full_coverage: return None @@ -5994,6 +6004,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): if saved and require_full_coverage: saved_snapshot = _snapshot( @@ -6135,6 +6146,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): if require_full_coverage: return None @@ -6286,6 +6298,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): if require_full_coverage: return None @@ -6420,6 +6433,7 @@ async def fake_load_snapshot( require_full_coverage=False, max_coverage_count=None, enforce_keep_first=True, + **kwargs, ): if require_full_coverage: return None @@ -6546,7 +6560,7 @@ async def noop_log(*args, **kwargs): async def noop_save_summary(*args, **kwargs): return None - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return [{"id": "ref-1", "role": "user", "content": "msg 1"}] self.filter._save_summary = noop_save_summary @@ -6622,7 +6636,7 @@ async def noop_log(*args, **kwargs): self.filter._save_summary = fake_save_summary self.filter._log = noop_log - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return [ {"id": "ref-1", "role": "user", "content": "Referenced question"}, {"id": "ref-2", "role": "assistant", "content": "Referenced answer"}, @@ -6696,7 +6710,7 @@ async def fake_save_summary(*args, **kwargs): self.filter._save_summary = fake_save_summary self.filter._log = noop_log - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return [ {"role": "user", "content": "msg 1"}, {"role": "assistant", "content": "msg 2"}, diff --git a/plugins/filters/async-context-compression/test_issue98_e2e.py b/plugins/filters/async-context-compression/test_issue98_e2e.py index 2357cb9..cb4867f 100644 --- a/plugins/filters/async-context-compression/test_issue98_e2e.py +++ b/plugins/filters/async-context-compression/test_issue98_e2e.py @@ -573,7 +573,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -762,7 +762,7 @@ async def fake_load_snapshots(chat_id): async def fake_load_live_refs(chat_id): return _live_refs_by_id(self.filter, db_messages) - async def fake_load_full_chat_messages(chat_id): + async def fake_load_full_chat_messages(chat_id, **kwargs): return db_messages async def noop(*args, **kwargs): @@ -787,6 +787,252 @@ async def noop(*args, **kwargs): self.assertTrue(self.filter._is_summary_message(result["messages"][0])) self.assertIn("ollama reasoning summary", result["messages"][0]["content"]) + # โ”€โ”€ Scenario 9: Branch divergence (user_message_id vs currentId) โ”€โ”€โ”€ + # Regression for Tuxie's bug: after a regeneration, currentId points at + # the regenerated branch tip while the inlet body was built by OpenWebUI + # from metadata['user_message_id']. Walking the parentId chain from the + # wrong anchor produces a different role sequence (mid-chain divergence). + # + # The assistant message carries an ``output`` array (reasoning model) so + # that ``process_messages_with_output`` rebuilds it IDESS in the body. + # This forces the primary ref-based selection to fail (``_current_branch_refs`` + # returns None) and routes through Path 3 โ€” the path that needs the DB + # branch to match the body. + + @staticmethod + def _build_branch_fork_history(): + """Build a chat history map with a regeneration fork. + + Topology: + U1 โ”€โ”€ A1(out) โ”€โ”€ U2 (original branch; A1 has output array) + โ””โ”€โ”€ A1'(out) (regenerated A1; currentId points here) + + - ``currentId`` = "a1prime" (regenerated branch tip) + - ``user_message_id`` = "u2" (on the ORIGINAL branch) + + OpenWebUI's ``load_messages_from_db(chat_id, "u2")`` walks: + U1 โ†’ A1 โ†’ U2 (3 messages, original branch) + + Walking from ``currentId`` ("a1prime") gives: + U1 โ†’ A1' (2 messages, regenerated branch) + + These are DIFFERENT branches โ€” Path 3 count/role check diverges. + """ + return { + "currentId": "a1prime", + "messages": { + "u1": {"id": "u1", "role": "user", "content": "Hello", "parentId": None}, + "a1": { + "id": "a1", + "role": "assistant", + "content": '
\nthinking\n
\nHi there', + "parentId": "u1", + "output": [ + {"type": "reasoning", "summary": [{"type": "output_text", "text": "thinking"}]}, + {"type": "message", "content": [{"type": "output_text", "text": "Hi there"}]}, + ], + }, + "u2": {"id": "u2", "role": "user", "content": "Bye", "parentId": "a1"}, + "a2": {"id": "a2", "role": "assistant", "content": "Goodbye", "parentId": "u2"}, + "a1prime": { + "id": "a1prime", + "role": "assistant", + "content": '
\nmore thinking\n
\nGreetings!', + "parentId": "u1", + "output": [ + {"type": "reasoning", "summary": [{"type": "output_text", "text": "more thinking"}]}, + {"type": "message", "content": [{"type": "output_text", "text": "Greetings!"}]}, + ], + }, + }, + } + + def _install_chat_record(self, history): + """Patch module-level Chats.get_chat_by_id to return a record with .chat.""" + chat_payload = {"history": history} + record = types.SimpleNamespace(chat=chat_payload) + + chats_module = sys.modules.get("open_webui.models.chats") + original_chats = chats_module.Chats if chats_module else None + + class _FakeChats: + @staticmethod + def get_chat_by_id(chat_id): + return record + + if chats_module: + chats_module.Chats = _FakeChats + module.Chats = _FakeChats + return original_chats, chats_module + + def _restore_chats(self, original_chats, chats_module): + if chats_module: + chats_module.Chats = original_chats + module.Chats = original_chats + + def test_load_full_chat_walks_from_anchor_when_provided(self): + """_load_full_chat_messages must walk from anchor_message_id (user_message_id), + NOT from currentId, when the anchor is available.""" + history = self._build_branch_fork_history() + original_chats, chats_module = self._install_chat_record(history) + try: + # With anchor = user_message_id "u2" โ†’ walks original branch + result = asyncio.run( + self.filter._load_full_chat_messages( + "chat-fork", anchor_message_id="u2" + ) + ) + ids = [m.get("id") for m in result] + self.assertEqual(ids, ["u1", "a1", "u2"], + "Must walk from user_message_id (original branch)") + + # Without anchor โ†’ falls back to currentId "a1prime" (regenerated branch) + result_no_anchor = asyncio.run( + self.filter._load_full_chat_messages("chat-fork") + ) + ids_no_anchor = [m.get("id") for m in result_no_anchor] + self.assertEqual(ids_no_anchor, ["u1", "a1prime"], + "Without anchor, must fall back to currentId (outlet path)") + finally: + self._restore_chats(original_chats, chats_module) + + def test_branch_divergence_inlet_injects_summary(self): + """Full inlet with branch divergence: body built from user_message_id branch, + currentId points at a different branch. Summary must still be injected + because the DB walk now follows the same anchor as the body. + + The assistant message has an ``output`` array so the body is mixed-id + (rebuilt assistant is idless) โ†’ primary ref-based selection fails โ†’ + Path 3 is reached โ†’ needs DB branch walked from user_message_id. + """ + history = self._build_branch_fork_history() + original_chats, chats_module = self._install_chat_record(history) + try: + # DB messages on the user_message_id branch (u1 โ†’ a1 โ†’ u2) + db_branch_messages = [ + history["messages"]["u1"], + history["messages"]["a1"], + history["messages"]["u2"], + ] + # Body as OpenWebUI would build it from user_message_id="u2" + body_messages = _build_body_from_db(db_branch_messages, reasoning_format=None) + # Confirm the body is mixed-id (assistant rebuilt idless) + self.assertIsNone(self.filter._get_message_id(body_messages[1])) + + snapshots = [ + _snapshot( + "branch-fork summary", + self.filter._message_refs_for_prefix(db_branch_messages, 2), + ) + ] + + async def fake_load_snapshots(chat_id): + return snapshots + + async def fake_load_live_refs(chat_id): + return _live_refs_by_id(self.filter, db_branch_messages) + + async def noop(*args, **kwargs): + return None + + self.filter._load_summary_snapshots = fake_load_snapshots + self.filter._load_chat_history_live_refs = fake_load_live_refs + # Do NOT mock _load_full_chat_messages โ€” let it use the real + # Chats.get_chat_by_id with the anchor_message_id fix. + self.filter._log = noop + self.filter._emit_debug_log = noop + self.filter._get_model_thresholds = lambda model_id: { + "max_context_tokens": 100000, + "compression_threshold_tokens": 1000, + } + + result = asyncio.run(self.filter.inlet( + { + "chat_id": "chat-fork", + "model": "test-model", + "messages": body_messages, + }, + __metadata__={ + "chat_id": "chat-fork", + "user_message_id": "u2", + }, + )) + + self.assertTrue( + self.filter._is_summary_message(result["messages"][0]), + f"Summary must be injected despite branch divergence. " + f"Got: {[m.get('role') for m in result['messages']]}", + ) + self.assertIn("branch-fork summary", result["messages"][0]["content"]) + finally: + self._restore_chats(original_chats, chats_module) + + def test_branch_divergence_without_anchor_fails(self): + """Without the anchor (no user_message_id in metadata), the DB walk + follows currentId and the branch diverges โ€” summary is NOT injected. + + This proves the fix is load-bearing: removing the anchor makes inlet + injection fail on regenerated chats. + """ + history = self._build_branch_fork_history() + original_chats, chats_module = self._install_chat_record(history) + try: + db_branch_messages = [ + history["messages"]["u1"], + history["messages"]["a1"], + history["messages"]["u2"], + ] + body_messages = _build_body_from_db(db_branch_messages, reasoning_format=None) + + snapshots = [ + _snapshot( + "branch-fork summary", + self.filter._message_refs_for_prefix(db_branch_messages, 2), + ) + ] + + async def fake_load_snapshots(chat_id): + return snapshots + + async def fake_load_live_refs(chat_id): + return _live_refs_by_id(self.filter, db_branch_messages) + + async def noop(*args, **kwargs): + return None + + self.filter._load_summary_snapshots = fake_load_snapshots + self.filter._load_chat_history_live_refs = fake_load_live_refs + self.filter._log = noop + self.filter._emit_debug_log = noop + self.filter._get_model_thresholds = lambda model_id: { + "max_context_tokens": 100000, + "compression_threshold_tokens": 1000, + } + + # NO user_message_id in metadata โ†’ anchor is None โ†’ walks currentId + result = asyncio.run(self.filter.inlet( + { + "chat_id": "chat-fork", + "model": "test-model", + "messages": body_messages, + }, + __metadata__={ + "chat_id": "chat-fork", + # user_message_id deliberately omitted + }, + )) + + # currentId walk gives [u1, a1prime] (2 messages, wrong branch). + # body has 3 messages from the original branch. + # Path 3 rejects (count mismatch 3 vs 2) โ†’ no summary injected. + self.assertFalse( + self.filter._is_summary_message(result["messages"][0]), + "Without user_message_id anchor, branch divergence must reject " + "(proving the anchor fix is load-bearing).", + ) + finally: + self._restore_chats(original_chats, chats_module) + if __name__ == "__main__": unittest.main(verbosity=2) From b3f81c0b7054793f7234752319886154e4d7955d Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 15:34:31 +0000 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- plugins/filters/metadata-id-debug/main.py | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 plugins/filters/metadata-id-debug/main.py diff --git a/plugins/filters/metadata-id-debug/main.py b/plugins/filters/metadata-id-debug/main.py new file mode 100644 index 0000000..18d8097 --- /dev/null +++ b/plugins/filters/metadata-id-debug/main.py @@ -0,0 +1,117 @@ +""" +title: Metadata ID Debug +author: Fu-Jie +description: Minimal inlet filter that prints all ID-related fields from __metadata__ to the OpenWebUI server console. Use to verify which IDs OpenWebUI actually sends to filters (chat_id, message_id, user_message_id, assistant_message_id, etc.). +version: 0.1.0 +license: MIT +""" + +from typing import Optional + + +class Filter: + class Valves(BaseModel): + priority: int = Field( + default=0, + description="Filter priority. Lower runs first. Set above the compression filter's priority so this prints BEFORE compression runs.", + ) + + def __init__(self): + self.valves = self.Valves() + + async def inlet( + self, + body: dict, + __metadata__: Optional[dict] = None, + ) -> dict: + # โ”€โ”€ Print everything OpenWebUI puts in __metadata__ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # This is the EXACT object OpenWebUI uses at middleware.py L2357-2361 + # to build the inlet body (chat_id + user_message_id). + print("\n" + "=" * 72) + print("[metadata-id-debug] INLET __metadata__ keys:") + print("=" * 72) + if not isinstance(__metadata__, dict): + print(f" __metadata__ is not a dict: {type(__metadata__).__name__} = {__metadata__!r}") + else: + for key in sorted(__metadata__.keys()): + value = __metadata__[key] + # Truncate long values (e.g. session_id, tool_ids list) + value_str = repr(value) + if len(value_str) > 120: + value_str = value_str[:117] + "..." + print(f" {key} = {value_str}") + + # โ”€โ”€ Highlight the IDs the compression fix depends on โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + print("-" * 72) + print("[metadata-id-debug] ID fields of interest:") + print("-" * 72) + if isinstance(__metadata__, dict): + for field in ( + "chat_id", + "message_id", + "user_message_id", # โ† the anchor the v1.7.3 fix uses + "assistant_message_id", # โ† used by OpenWebUI's continue path + ): + present = field in __metadata__ + value = __metadata__.get(field) + marker = "PRESENT" if present else "ABSENT " + print(f" [{marker}] {field:24} = {value!r}") + else: + print(" __metadata__ missing or not a dict") + + # โ”€โ”€ Also dump body.metadata (frontend-supplied) for comparison โ”€โ”€โ”€ + body_meta = body.get("metadata") if isinstance(body, dict) else None + print("-" * 72) + print("[metadata-id-debug] body.metadata (frontend-supplied):") + print("-" * 72) + if isinstance(body_meta, dict): + for key in sorted(body_meta.keys()): + value_str = repr(body_meta[key]) + if len(value_str) > 120: + value_str = value_str[:117] + "..." + print(f" {key} = {value_str}") + else: + print(f" body.metadata is not a dict: {type(body_meta).__name__}") + + # โ”€โ”€ Message count + branch tip info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + messages = body.get("messages") if isinstance(body, dict) else None + if isinstance(messages, list): + print("-" * 72) + print(f"[metadata-id-debug] body.messages count = {len(messages)}") + last = messages[-1] if messages else None + if isinstance(last, dict): + print( + f" last message: role={last.get('role')!r} " + f"id={last.get('id')!r} has_output={'output' in last}" + ) + + print("=" * 72 + "\n") + return body + + async def outlet( + self, + body: dict, + __metadata__: Optional[dict] = None, + ) -> dict: + # Outlet is also called with the same metadata shape; print it too + # so we can compare inlet vs outlet metadata. + print("\n" + "=" * 72) + print("[metadata-id-debug] OUTLET __metadata__ keys:") + print("=" * 72) + if isinstance(__metadata__, dict): + for key in sorted(__metadata__.keys()): + value_str = repr(__metadata__[key]) + if len(value_str) > 120: + value_str = value_str[:117] + "..." + print(f" {key} = {value_str}") + else: + print(f" __metadata__ is not a dict: {type(__metadata__).__name__}") + print("-" * 72) + if isinstance(__metadata__, dict): + for field in ("chat_id", "message_id", "user_message_id", "assistant_message_id"): + present = field in __metadata__ + value = __metadata__.get(field) + marker = "PRESENT" if present else "ABSENT " + print(f" [{marker}] {field:24} = {value!r}") + print("=" * 72 + "\n") + return body From 1f471afed4040e1d824434ccf93194d38bf0804a Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 15:37:49 +0000 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- plugins/filters/metadata-id-debug/main.py | 187 ++++++++++++---------- 1 file changed, 101 insertions(+), 86 deletions(-) diff --git a/plugins/filters/metadata-id-debug/main.py b/plugins/filters/metadata-id-debug/main.py index 18d8097..1334b8d 100644 --- a/plugins/filters/metadata-id-debug/main.py +++ b/plugins/filters/metadata-id-debug/main.py @@ -1,117 +1,132 @@ """ title: Metadata ID Debug author: Fu-Jie -description: Minimal inlet filter that prints all ID-related fields from __metadata__ to the OpenWebUI server console. Use to verify which IDs OpenWebUI actually sends to filters (chat_id, message_id, user_message_id, assistant_message_id, etc.). -version: 0.1.0 +description: Minimal inlet/outlet filter that prints all ID-related fields from __metadata__ to the BROWSER console (DevTools) via __event_emitter__ execute events. Use to verify which IDs OpenWebUI actually sends to filters (chat_id, message_id, user_message_id, assistant_message_id, etc.). +version: 0.2.0 license: MIT """ -from typing import Optional +import json +from typing import Any, Awaitable, Callable, Optional class Filter: class Valves(BaseModel): priority: int = Field( default=0, - description="Filter priority. Lower runs first. Set above the compression filter's priority so this prints BEFORE compression runs.", + description="Filter priority. Set LOWER than the compression filter so this runs first and prints the raw metadata.", ) def __init__(self): self.valves = self.Valves() - async def inlet( + # โ”€โ”€ Browser console emitter (same pattern as folder-memory plugin) โ”€โ”€ + # OpenWebUI frontend executes the JS in data.code; console.log then + # appears in the browser DevTools Console (F12). + async def _console_log( self, - body: dict, - __metadata__: Optional[dict] = None, - ) -> dict: - # โ”€โ”€ Print everything OpenWebUI puts in __metadata__ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - # This is the EXACT object OpenWebUI uses at middleware.py L2357-2361 - # to build the inlet body (chat_id + user_message_id). - print("\n" + "=" * 72) - print("[metadata-id-debug] INLET __metadata__ keys:") - print("=" * 72) + __event_emitter__: Optional[Callable[[Any], Awaitable[None]]], + label: str, + payload: Any, + ) -> None: + if not __event_emitter__: + return + try: + # JSON-serialize the payload so it survives embedding in JS. + # ensure_ascii=False keeps non-ASCII readable in the console. + json_str = json.dumps(payload, ensure_ascii=False, default=str) + js_code = f'console.log("[metadata-id-debug] {label}", {json_str});' + await __event_emitter__( + {"type": "execute", "data": {"code": js_code}} + ) + except Exception as exc: + # Fall back silently โ€” this is a debug filter, never break the chat. + print(f"[metadata-id-debug] emit failed: {exc}") + + def _summarize_metadata(self, __metadata__: Any) -> dict: + """Build a compact dict of the metadata fields we care about.""" if not isinstance(__metadata__, dict): - print(f" __metadata__ is not a dict: {type(__metadata__).__name__} = {__metadata__!r}") - else: - for key in sorted(__metadata__.keys()): - value = __metadata__[key] - # Truncate long values (e.g. session_id, tool_ids list) - value_str = repr(value) - if len(value_str) > 120: - value_str = value_str[:117] + "..." - print(f" {key} = {value_str}") + return {"_error": f"__metadata__ is not a dict: {type(__metadata__).__name__}"} + + # All keys (sorted) so we can see the full shape OpenWebUI sends. + all_keys = sorted(__metadata__.keys()) + + # ID fields the v1.7.3 branch-divergence fix depends on. + ids = {} + for field in ( + "chat_id", + "message_id", + "user_message_id", # โ† the anchor the v1.7.3 fix uses + "assistant_message_id", # โ† used by OpenWebUI's continue path + ): + ids[field] = { + "present": field in __metadata__, + "value": __metadata__.get(field), + } + + return {"all_keys": all_keys, "ids_of_interest": ids} + + def _summarize_body(self, body: dict) -> dict: + """Compact body summary (avoid dumping the whole messages array).""" + if not isinstance(body, dict): + return {"_error": f"body is not a dict: {type(body).__name__}"} - # โ”€โ”€ Highlight the IDs the compression fix depends on โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - print("-" * 72) - print("[metadata-id-debug] ID fields of interest:") - print("-" * 72) - if isinstance(__metadata__, dict): - for field in ( - "chat_id", - "message_id", - "user_message_id", # โ† the anchor the v1.7.3 fix uses - "assistant_message_id", # โ† used by OpenWebUI's continue path - ): - present = field in __metadata__ - value = __metadata__.get(field) - marker = "PRESENT" if present else "ABSENT " - print(f" [{marker}] {field:24} = {value!r}") - else: - print(" __metadata__ missing or not a dict") + messages = body.get("messages") + last = None + if isinstance(messages, list) and messages: + last_msg = messages[-1] + if isinstance(last_msg, dict): + last = { + "role": last_msg.get("role"), + "id": last_msg.get("id"), + "has_output": "output" in last_msg, + } - # โ”€โ”€ Also dump body.metadata (frontend-supplied) for comparison โ”€โ”€โ”€ - body_meta = body.get("metadata") if isinstance(body, dict) else None - print("-" * 72) - print("[metadata-id-debug] body.metadata (frontend-supplied):") - print("-" * 72) - if isinstance(body_meta, dict): - for key in sorted(body_meta.keys()): - value_str = repr(body_meta[key]) - if len(value_str) > 120: - value_str = value_str[:117] + "..." - print(f" {key} = {value_str}") - else: - print(f" body.metadata is not a dict: {type(body_meta).__name__}") + body_meta = body.get("metadata") + body_meta_keys = sorted(body_meta.keys()) if isinstance(body_meta, dict) else None - # โ”€โ”€ Message count + branch tip info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - messages = body.get("messages") if isinstance(body, dict) else None - if isinstance(messages, list): - print("-" * 72) - print(f"[metadata-id-debug] body.messages count = {len(messages)}") - last = messages[-1] if messages else None - if isinstance(last, dict): - print( - f" last message: role={last.get('role')!r} " - f"id={last.get('id')!r} has_output={'output' in last}" - ) + return { + "message_count": len(messages) if isinstance(messages, list) else None, + "last_message": last, + "body_metadata_keys": body_meta_keys, + } - print("=" * 72 + "\n") + async def inlet( + self, + body: dict, + __metadata__: Optional[dict] = None, + __event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None, + ) -> dict: + # โ”€โ”€ Emit to browser console (DevTools โ†’ Console) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + await self._console_log( + __event_emitter__, + "INLET __metadata__", + self._summarize_metadata(__metadata__), + ) + await self._console_log( + __event_emitter__, + "INLET body summary", + self._summarize_body(body), + ) return body async def outlet( self, body: dict, __metadata__: Optional[dict] = None, + __event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None, ) -> dict: - # Outlet is also called with the same metadata shape; print it too - # so we can compare inlet vs outlet metadata. - print("\n" + "=" * 72) - print("[metadata-id-debug] OUTLET __metadata__ keys:") - print("=" * 72) - if isinstance(__metadata__, dict): - for key in sorted(__metadata__.keys()): - value_str = repr(__metadata__[key]) - if len(value_str) > 120: - value_str = value_str[:117] + "..." - print(f" {key} = {value_str}") - else: - print(f" __metadata__ is not a dict: {type(__metadata__).__name__}") - print("-" * 72) - if isinstance(__metadata__, dict): - for field in ("chat_id", "message_id", "user_message_id", "assistant_message_id"): - present = field in __metadata__ - value = __metadata__.get(field) - marker = "PRESENT" if present else "ABSENT " - print(f" [{marker}] {field:24} = {value!r}") - print("=" * 72 + "\n") + # Outlet is called with the same metadata shape; emit it too so we + # can compare inlet vs outlet metadata (e.g. whether assistant_message_id + # appears only at outlet time). + await self._console_log( + __event_emitter__, + "OUTLET __metadata__", + self._summarize_metadata(__metadata__), + ) + await self._console_log( + __event_emitter__, + "OUTLET body summary", + self._summarize_body(body), + ) return body From 2866cd4fe1bc99481692bbff7b8c82380c9a7df8 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 16:34:24 +0000 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- plugins/filters/metadata-id-debug/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/filters/metadata-id-debug/main.py b/plugins/filters/metadata-id-debug/main.py index 1334b8d..b6664e2 100644 --- a/plugins/filters/metadata-id-debug/main.py +++ b/plugins/filters/metadata-id-debug/main.py @@ -9,6 +9,8 @@ import json from typing import Any, Awaitable, Callable, Optional +from pydantic import BaseModel, Field + class Filter: class Valves(BaseModel): From 83c1cbecf1b6fb4512499bdebc30b68f4f40b24b Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 17:46:52 +0000 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- .../async_context_compression.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 3a4abad..531e5ea 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -864,6 +864,13 @@ def _get_cached_tokens(text: str) -> int: class Filter: def __init__(self): self.valves = self.Valves() + # Diagnostic stash: set by _body_to_db_coverage_map_for_ref_fallback + # when Path 3 rejects, read by inlet() to emit to the browser console. + self._last_path3_rejection = None + # Diagnostic stash: set by _load_full_chat_messages to record which + # anchor was used for the DB walk and whether it diverged from + # history.currentId. Read by inlet() to emit to the browser console. + self._last_db_walk_anchor = None self._owui_db = owui_db self._db_engine = owui_engine self._fallback_session_factory = ( @@ -1881,10 +1888,28 @@ async def _load_full_chat_messages( history_messages = history.get("messages") if isinstance(history_messages, dict) and history_messages: walk_anchor = None + anchor_source = None if isinstance(anchor_message_id, str) and anchor_message_id in history_messages: walk_anchor = anchor_message_id + anchor_source = "user_message_id" if not walk_anchor: walk_anchor = history.get("currentId") or history.get("current_id") + anchor_source = "currentId (fallback)" + # Stash the anchor diagnostic so inlet() can emit it to the + # browser console. When anchor_source is "currentId (fallback)" + # the branch-divergence fix is NOT active (anchor missing), + # and mismatches can occur after regeneration/edit. + current_id_in_history = history.get("currentId") or history.get("current_id") + self._last_db_walk_anchor = { + "anchor": walk_anchor, + "source": anchor_source, + "currentId": current_id_in_history, + "diverged": ( + walk_anchor != current_id_in_history + if walk_anchor and current_id_in_history + else False + ), + } branch_messages = self._reconstruct_active_history_branch( history_messages, walk_anchor ) @@ -2614,10 +2639,21 @@ def _body_to_db_coverage_map_for_ref_fallback( ) if first_mismatch is not None: mismatch_index, mismatch_reason = first_mismatch + # Stash for the inlet to emit to the browser console so users + # can see exactly which position/field caused the rejection + # without digging through backend logs. + self._last_path3_rejection = ( + mismatch_index, + mismatch_reason, + ) logger.info( "[Summary Snapshot] Path 3 position fallback rejected at " f"index={mismatch_index}: {mismatch_reason}" ) + else: + self._last_path3_rejection = None + else: + self._last_path3_rejection = None return None @@ -5753,12 +5789,60 @@ async def inlet( if isinstance(__metadata__, dict) else None ) + # Diagnostic: show which anchor the DB walk will use, and whether it + # diverges from history.currentId. When these differ, the filter is + # relying on the v1.7.3 branch-divergence fix to walk the SAME branch + # the body came from. If currentId fallback is used instead (anchor + # missing), branch mismatches can silently reject the summary. + await self._log( + f"[Inlet] ๐Ÿ“ DB walk anchor: user_message_id={inlet_user_message_id!r}", + event_call=__event_call__, + ) summary_snapshot = await self._load_applicable_summary_snapshot( chat_id, messages, anchor_message_id=inlet_user_message_id, ) + # Diagnostic: emit the actual DB walk result โ€” which anchor was used + # (user_message_id vs currentId fallback) and whether it diverged from + # history.currentId. "diverged=true" means the branch-divergence fix + # is actively steering the walk onto the body's branch; if the summary + # is then rejected with a role mismatch, the fix is incomplete. + if self._last_db_walk_anchor is not None: + anc = self._last_db_walk_anchor + diverged_marker = " โš ๏ธ DIVERGED from currentId" if anc.get("diverged") else "" + await self._log( + f"[Inlet] ๐Ÿงญ DB walk used {anc.get('source')}: " + f"anchor={anc.get('anchor')!r} currentId={anc.get('currentId')!r}" + f"{diverged_marker}", + event_call=__event_call__, + ) + self._last_db_walk_anchor = None + + # Diagnostic: show whether a branch-valid summary was found. When + # this is None despite a summary existing in the DB, the branch-validity + # check rejected it โ€” look for the Path 3 rejection log below to see why. + await self._log( + f"[Inlet] ๐Ÿ“ฆ Summary snapshot: {'FOUND (will inject)' if summary_snapshot else 'NONE (full context sent)'}", + event_call=__event_call__, + ) + + # Diagnostic: if Path 3 (position-based fallback) rejected the body, + # surface the exact mismatch index/reason in the browser console so + # users can see WHY the summary was dropped without digging through + # backend logs. This is the key signal for branch-divergence bugs: + # a "role mismatch" at equal length means the DB walk and body walk + # are on different branches. + if self._last_path3_rejection is not None: + rej_index, rej_reason = self._last_path3_rejection + await self._log( + f"[Inlet] โš ๏ธ Path 3 rejected at index={rej_index}: {rej_reason}", + log_type="warning", + event_call=__event_call__, + ) + self._last_path3_rejection = None + # Calculate effective_keep_first to ensure all system messages are protected effective_keep_first = self._get_effective_keep_first(messages) From 78c05a74d6624628624a3c26d0b4853596a6b048 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 20:48:48 +0000 Subject: [PATCH 15/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- .../async_context_compression.py | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index 531e5ea..b399190 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -1899,13 +1899,40 @@ async def _load_full_chat_messages( # browser console. When anchor_source is "currentId (fallback)" # the branch-divergence fix is NOT active (anchor missing), # and mismatches can occur after regeneration/edit. + # + # "diverged" here means TRUE branch divergence: the anchor + # (user_message_id) is NOT on the currentId ancestor chain. + # Simply comparing anchor != currentId is useless because they + # are always different nodes (user vs assistant message). We + # walk up parentId from currentId; if we reach the anchor, both + # are on the same branch (currentId is a descendant of the + # user_message_id, the normal case). If not, the body and DB + # walk are on different branches โ€” the v1.7.3 fix is actively + # steering the DB walk onto the body's branch. current_id_in_history = history.get("currentId") or history.get("current_id") + same_branch = False + if walk_anchor and current_id_in_history: + cursor = current_id_in_history + visited = set() + while ( + cursor + and cursor in history_messages + and cursor not in visited + ): + if cursor == walk_anchor: + same_branch = True + break + visited.add(cursor) + cursor = ( + history_messages[cursor].get("parentId") + or history_messages[cursor].get("parent_id") + ) self._last_db_walk_anchor = { "anchor": walk_anchor, "source": anchor_source, "currentId": current_id_in_history, "diverged": ( - walk_anchor != current_id_in_history + not same_branch if walk_anchor and current_id_in_history else False ), @@ -5805,13 +5832,20 @@ async def inlet( ) # Diagnostic: emit the actual DB walk result โ€” which anchor was used - # (user_message_id vs currentId fallback) and whether it diverged from - # history.currentId. "diverged=true" means the branch-divergence fix - # is actively steering the walk onto the body's branch; if the summary - # is then rejected with a role mismatch, the fix is incomplete. + # (user_message_id vs currentId fallback) and whether the anchor is on + # the same branch as currentId. "TRUE DIVERGENCE" means the + # user_message_id is NOT an ancestor of currentId, so the body and DB + # walk are on different branches โ€” the v1.7.3 fix is actively steering + # the DB walk onto the body's branch. If a "same branch" result is + # shown, the fix and the legacy currentId walk would produce the same + # branch (the fix is a no-op for this request). if self._last_db_walk_anchor is not None: anc = self._last_db_walk_anchor - diverged_marker = " โš ๏ธ DIVERGED from currentId" if anc.get("diverged") else "" + diverged_marker = ( + " โš ๏ธ TRUE DIVERGENCE (anchor not on currentId branch)" + if anc.get("diverged") + else " (same branch as currentId)" + ) await self._log( f"[Inlet] ๐Ÿงญ DB walk used {anc.get('source')}: " f"anchor={anc.get('anchor')!r} currentId={anc.get('currentId')!r}" From c3aefb2ceaa1c71ed3c5555f4594e5eda6604a59 Mon Sep 17 00:00:00 2001 From: Fu-Jie Date: Mon, 29 Jun 2026 21:56:56 +0000 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20=E6=9F=A5=E7=9C=8B=20GitHub=20Pul?= =?UTF-8?q?l=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- .../async_context_compression.py | 25 ++----------------- .../test_async_context_compression.py | 24 +++++++++++++----- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/plugins/filters/async-context-compression/async_context_compression.py b/plugins/filters/async-context-compression/async_context_compression.py index b399190..f7fa1c0 100644 --- a/plugins/filters/async-context-compression/async_context_compression.py +++ b/plugins/filters/async-context-compression/async_context_compression.py @@ -1835,23 +1835,6 @@ def _reconstruct_active_history_branch( sortable_messages.sort(key=lambda item: (item[0], item[1])) return [message for _, _, message in sortable_messages] - def _is_failed_assistant_message(self, message: Dict[str, Any]) -> bool: - """Mirror OpenWebUI middleware's failed-assistant filter.""" - return ( - isinstance(message, dict) - and message.get("role") == "assistant" - and "error" in message - ) - - def _filter_model_visible_history_messages( - self, messages: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: - return [ - message - for message in messages - if not self._is_failed_assistant_message(message) - ] - async def _load_full_chat_messages( self, chat_id: str, @@ -1941,15 +1924,11 @@ async def _load_full_chat_messages( history_messages, walk_anchor ) if branch_messages: - return self._filter_model_visible_history_messages( - branch_messages - ) + return branch_messages direct_messages = chat_payload.get("messages") if isinstance(direct_messages, list) and direct_messages: - return self._filter_model_visible_history_messages( - deepcopy(direct_messages) - ) + return deepcopy(direct_messages) return [] diff --git a/plugins/filters/async-context-compression/test_async_context_compression.py b/plugins/filters/async-context-compression/test_async_context_compression.py index f7b10f5..c5ef20a 100644 --- a/plugins/filters/async-context-compression/test_async_context_compression.py +++ b/plugins/filters/async-context-compression/test_async_context_compression.py @@ -3217,7 +3217,11 @@ def get_chat_by_id(chat_id): self.assertEqual([message["id"] for message in messages], ["m1", "m2", "m3"]) self.assertEqual(messages[2]["role"], "tool") - def test_load_full_chat_messages_filters_failed_assistant_from_history_branch(self): + def test_load_full_chat_messages_keeps_failed_assistant_to_match_owui_body(self): + # OpenWebUI's middleware.load_messages_from_db does NOT filter failed + # assistant messages โ€” it only strips fields to (role, content, output, + # files). The filter's DB walk must match that behaviour so the + # index-by-index alignment with the request body holds. See issue #98. class FakeChats: @staticmethod def get_chat_by_id(chat_id): @@ -3262,10 +3266,15 @@ def get_chat_by_id(chat_id): finally: module.Chats = original_chats - self.assertEqual([message["id"] for message in messages], ["m1", "m3", "m4"]) - self.assertFalse(any("error" in message for message in messages)) + # m2 (failed assistant) MUST be retained โ€” dropping it would shift + # every subsequent index and cause role-mismatch against the body. + self.assertEqual( + [message["id"] for message in messages], ["m1", "m2", "m3", "m4"] + ) + self.assertEqual([message["role"] for message in messages], + ["user", "assistant", "user", "assistant"]) - def test_load_full_chat_messages_filters_failed_assistant_from_direct_messages(self): + def test_load_full_chat_messages_keeps_failed_assistant_in_direct_messages(self): class FakeChats: @staticmethod def get_chat_by_id(chat_id): @@ -3292,8 +3301,11 @@ def get_chat_by_id(chat_id): finally: module.Chats = original_chats - self.assertEqual([message["id"] for message in messages], ["m1", "m3", "m4"]) - self.assertFalse(any("error" in message for message in messages)) + self.assertEqual( + [message["id"] for message in messages], ["m1", "m2", "m3", "m4"] + ) + self.assertEqual([message["role"] for message in messages], + ["user", "assistant", "user", "assistant"]) def test_load_authorized_chat_messages_uses_owner_helper(self): class FakeChats: