feat(async-context-compression): add manual invoke ability (closes #80)#92
feat(async-context-compression): add manual invoke ability (closes #80)#92Fu-Jie wants to merge 2 commits into
Conversation
Add an HTTP endpoint POST /api/v1/filters/async-context-compression/compress that triggers context compression for a chat without first sending a request to the model. This solves cloned chats losing compression, imported chat JSON with no summary, and switching to a smaller-context model mid-chat. The endpoint reuses the exact same compression pipeline as the automatic outlet trigger (threshold check, hysteresis guard, atomic-group alignment, optimistic lock), so it is idempotent and safe to call repeatedly. An optional force flag bypasses threshold/hysteresis guards for debugging/rescue. - Filter.manual_compress(): new method reusing _generate_summary_async - register_manual_compress_endpoint(): idempotent route registration on webui_app - Bump version 1.6.5 -> 1.6.6 - Document the endpoint in README.md and README_CN.md
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
✅ Plugin Version Check / 插件版本检查版本更新检测通过!PR 包含版本变化和更新说明。 Version check passed! PR contains version changes and update description. 版本变化 / Version ChangesPlugin Updates
This comment was generated automatically. / 此评论由自动生成。 |
There was a problem hiding this comment.
Code Review
此拉取请求为异步上下文压缩过滤器新增了手动触发压缩的 HTTP 端点(POST /api/v1/filters/async-context-compression/compress),并同步更新了中英文文档。审查意见指出了一些需要改进的问题:首先,FastAPI 路由中未正确导入和使用 Depends(bearer_scheme),导致 Bearer Token 无法自动提取而退回到匿名上下文;其次,在 manual_compress 中存在重复读取数据库记录的开销;此外,直接调用包含同步数据库查询的 _get_model_thresholds 违反了异步 I/O 规范,应使用 asyncio.to_thread 包装;最后,接口返回的 status 判断逻辑不够准确,应通过对比压缩计数是否增加来确定是否成功生成了新摘要。
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| from fastapi import HTTPException, status | ||
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer |
There was a problem hiding this comment.
为了在 FastAPI 路由中正确使用 Depends(bearer_scheme) 依赖注入,需要从 fastapi 导入 Depends。
| from fastapi import HTTPException, status | |
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer | |
| from fastapi import Depends, HTTPException, status | |
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer |
| async def _manual_compress_endpoint( | ||
| payload: Dict[str, Any], | ||
| credentials: Optional[HTTPAuthorizationCredentials] = None, | ||
| ) -> Dict[str, Any]: |
There was a problem hiding this comment.
在 FastAPI 中,如果不使用 Depends(bearer_scheme),credentials 参数将无法从 HTTP 请求的 Authorization 头部自动提取 Bearer Token,而是会被当作普通的请求体或查询参数解析,导致其值始终为 None。这会使得 _resolve_user 总是退回到匿名上下文(Anonymous)。
建议使用 Depends(bearer_scheme) 来正确注入凭证。
| async def _manual_compress_endpoint( | |
| payload: Dict[str, Any], | |
| credentials: Optional[HTTPAuthorizationCredentials] = None, | |
| ) -> Dict[str, Any]: | |
| async def _manual_compress_endpoint( | |
| payload: Dict[str, Any], | |
| credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), | |
| ) -> Dict[str, Any]: |
| if not any(self._is_summary_message(m) for m in summary_messages): | ||
| existing_record = await self._load_summary_record(chat_id) | ||
| if existing_record and existing_record.compressed_message_count > 0: | ||
| boundary = min( | ||
| existing_record.compressed_message_count, len(summary_messages) | ||
| ) | ||
| injected_summary_msg = self._build_summary_message( | ||
| existing_record.summary, | ||
| lang, | ||
| existing_record.compressed_message_count, | ||
| ) | ||
| summary_messages = ( | ||
| summary_messages[:boundary] | ||
| + [injected_summary_msg] | ||
| + summary_messages[boundary:] | ||
| ) | ||
|
|
||
| previous_record = await self._load_summary_record(chat_id) | ||
| previous_compressed_count = ( | ||
| previous_record.compressed_message_count if previous_record else 0 | ||
| ) |
There was a problem hiding this comment.
这里存在重复读取数据库的操作。existing_record 在第 3993 行已经通过 _load_summary_record 加载过一次,而在第 4009 行又重新调用 _load_summary_record 加载并赋值给 previous_record。我们可以先加载 existing_record,然后根据需要进行注入,最后直接将 existing_record 赋值给 previous_record,从而避免不必要的数据库 I/O 开销。
existing_record = await self._load_summary_record(chat_id)
if not any(self._is_summary_message(m) for m in summary_messages):
if existing_record and existing_record.compressed_message_count > 0:
boundary = min(
existing_record.compressed_message_count, len(summary_messages)
)
injected_summary_msg = self._build_summary_message(
existing_record.summary,
lang,
existing_record.compressed_message_count,
)
summary_messages = (
summary_messages[:boundary]
+ [injected_summary_msg]
+ summary_messages[boundary:]
)
previous_record = existing_record
previous_compressed_count = (
previous_record.compressed_message_count if previous_record else 0
)| thresholds = ( | ||
| self._get_model_thresholds(effective_model_id) | ||
| if effective_model_id | ||
| else { | ||
| "compression_threshold_tokens": self.valves.compression_threshold_tokens, | ||
| "max_context_tokens": self.valves.max_context_tokens, | ||
| } | ||
| ) |
There was a problem hiding this comment.
根据项目规范(Non-Negotiable Rules 第 5 条:Async I/O only — wrap sync calls with asyncio.to_thread),在异步上下文中执行同步 I/O 操作时,应当使用 asyncio.to_thread 进行包装。_get_model_thresholds 内部调用了同步的数据库查询 _call_db_sync,直接在 async def manual_compress 中调用会阻塞事件循环。建议使用 asyncio.to_thread 异步执行该方法。
| thresholds = ( | |
| self._get_model_thresholds(effective_model_id) | |
| if effective_model_id | |
| else { | |
| "compression_threshold_tokens": self.valves.compression_threshold_tokens, | |
| "max_context_tokens": self.valves.max_context_tokens, | |
| } | |
| ) | |
| thresholds = ( | |
| await asyncio.to_thread(self._get_model_thresholds, effective_model_id) | |
| if effective_model_id | |
| else { | |
| "compression_threshold_tokens": self.valves.compression_threshold_tokens, | |
| "max_context_tokens": self.valves.max_context_tokens, | |
| } | |
| ) |
References
- Async I/O only — wrap sync calls with asyncio.to_thread (link)
| return { | ||
| "status": "compressed" if new_record else "skipped", | ||
| "chat_id": chat_id, | ||
| "message_count": len(messages), | ||
| "current_tokens": current_tokens, | ||
| "summary_tokens": new_summary_tokens, | ||
| "threshold": compression_threshold_tokens, | ||
| "previous_compressed_count": previous_compressed_count, | ||
| "compressed_count": new_compressed_count, | ||
| "forced": force, | ||
| } |
There was a problem hiding this comment.
这里的 status 判断逻辑不够准确。如果该会话之前已经存在压缩记录(previous_record 不为 None),且本次调用由于某种原因(例如在 _generate_summary_async 内部被跳过或静默失败)并未实际生成新的摘要,new_record 依然不为 None。这会导致接口返回 "status": "compressed",而实际上并没有发生新的压缩,这与文档中 compressed 表示“生成了新摘要并已持久化”的定义不符。
建议将判断条件改为 new_compressed_count > previous_compressed_count,以确保只有在压缩边界实际推进时才返回 "status": "compressed"。
| return { | |
| "status": "compressed" if new_record else "skipped", | |
| "chat_id": chat_id, | |
| "message_count": len(messages), | |
| "current_tokens": current_tokens, | |
| "summary_tokens": new_summary_tokens, | |
| "threshold": compression_threshold_tokens, | |
| "previous_compressed_count": previous_compressed_count, | |
| "compressed_count": new_compressed_count, | |
| "forced": force, | |
| } | |
| return { | |
| "status": "compressed" if new_compressed_count > previous_compressed_count else "skipped", | |
| "chat_id": chat_id, | |
| "message_count": len(messages), | |
| "current_tokens": current_tokens, | |
| "summary_tokens": new_summary_tokens, | |
| "threshold": compression_threshold_tokens, | |
| "previous_compressed_count": previous_compressed_count, | |
| "compressed_count": new_compressed_count, | |
| "forced": force, | |
| } |
…act command (closes #80) The HTTP endpoint approach (POST /api/v1/filters/async-context-compression/compress) is unworkable in practice: Open WebUI filters run inside the main process without an independent HTTP service, app.add_api_route pollutes the host app's route namespace, and users cannot invoke the endpoint from the Open WebUI frontend. Replace it with an inlet command-recognition approach: when the latest user message is /compact, /compress, or /summary (case-insensitive), the filter triggers compression via manual_compress() and replaces the command text with a system notice so the LLM relays the outcome verbatim. The command is never sent to the LLM as a literal prompt. - Remove all HTTP endpoint code: _MANUAL_ROUTE_*, register_manual_compress_endpoint, _manual_compress_endpoint, _resolve_user, _get_manual_filter_instance, _MANUAL_FILTER_INSTANCE, app.add_api_route, import-time registration - Add _try_handle_manual_command() in inlet flow (called after chat_id is resolved) - Support /compact, /compress, /summary (case-insensitive) - Support force via trailing ! (e.g. /compact!) or force argument (e.g. /compact force) - Keep manual_compress() core method (reuses _generate_summary_async pipeline) - Add manual_compress_min_messages valve (default 4, bypassed by force) - Add 3 new i18n keys x 12 languages (manual_compressed, manual_skipped, manual_error) - Bump version 1.6.6 -> 1.6.7 - Update README.md and README_CN.md
Summary
Implements manual invoke ability for async-context-compression requested in #80.
Adds a public
manual_compress()method on the Filter that bypasses threshold/hysteresis guards and reuses the existing compression pipeline (_load_full_chat_messages/_calculate_target_compressed_count/_get_chat_lock/_generate_summary_async/_save_summary). An HTTP endpointPOST /api/v1/filters/async-context-compression/compressis registered on the Open WebUI app for programmatic invocation.Changes
plugins/filters/async-context-compression/async_context_compression.py(+453/-1)async def manual_compress(chat_id, model, user_data, ...)methodmanual_compress_min_messagesvalve (default 4)webui_appplugins/filters/async-context-compression/README.md(+89/-1)plugins/filters/async-context-compression/README_CN.md(+89/-1)Use Cases
Test Plan
manual_compress()returns correct compression resultskippedunlessforce=Trueskippedwithchat_not_found_or_emptyUsage
Closes #80