Compare commits

..

10 Commits

Author SHA1 Message Date
fdc310 cd7acd5769 style(space): apply ruff formatting 2026-08-26 00:13:04 +08:00
fdc310 51b9e1cf54 Merge remote-tracking branch 'origin/master' into feat/rework-agent-onboarding 2026-08-25 22:36:44 +08:00
fdc310 1f07471f81 feat(wizard): label page bot test preview 2026-08-25 22:34:45 +08:00
fdc310 7f29d51c69 feat(wizard): streamline custom model onboarding 2026-08-25 19:33:36 +08:00
fdc310 6b60ad1678 fix(wizard): repair HTTP bot inbound test setup 2026-08-23 20:41:46 +08:00
langbot-dev 7a4fadc375 feat(wizard): add floating page bot verification 2026-08-14 23:58:39 +08:00
langbot-dev f2ba540ffb feat(wizard): add inbound bot verification 2026-08-14 23:14:22 +08:00
langbot-dev 97b176aef2 fix(wizard): parse ranked model selection entries 2026-08-14 17:43:36 +08:00
langbot-dev 7c387f75e1 fix(web): support LAN development access 2026-08-14 17:29:21 +08:00
langbot-dev b0566f4c9d feat(wizard): rework agent onboarding flow 2026-08-14 01:10:00 +08:00
211 changed files with 908 additions and 12583 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: 漏洞反馈
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
title: "[Bug]: "
labels: ["bug?"]
body:
@@ -22,7 +22,7 @@ body:
- type: textarea
attributes:
label: 异常情况
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
validations:
required: true
- type: textarea
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
title: "[Bug]: "
labels: ["bug?"]
body:
+2 -2
View File
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
## Where to Look
- Architecture map: `ARCHITECTURE.md`.
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
- API-key auth: `docs/API_KEY_AUTH.md`.
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
+6 -6
View File
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Website</a>
<a href="https://langbot.app/docs/en/insight/features">Features</a>
<a href="https://langbot.app/docs/en/insight/guide">Docs</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">Features</a>
<a href="https://link.langbot.app/en/docs/guide">Docs</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
[→ Learn more about all features](https://langbot.app/docs/en/insight/features)
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -89,7 +89,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://langbot.app/docs/en/insight/features)
[→ View all integrations](https://link.langbot.app/en/docs/features)
---
+6 -6
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官网</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文档</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文档</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">扩展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
@@ -89,7 +89,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
---
@@ -152,7 +152,7 @@ docker compose --profile all up -d
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
### TTS(语音合成)
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Inicio</a>
<a href="https://langbot.app/docs/en/insight/features">Características</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">Características</a>
<a href="https://link.langbot.app/en/docs/guide">Documentación</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Accueil</a>
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a>
<a href="https://link.langbot.app/en/docs/guide">Documentation</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">ホーム</a>
<a href="https://langbot.app/docs/ja/insight/features">機能</a>
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a>
<a href="https://langbot.app/docs/ja/tags/readme">API</a>
<a href="https://link.langbot.app/ja/docs/features">機能</a>
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a>
<a href="https://link.langbot.app/ja/docs/api">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">홈</a>
<a href="https://langbot.app/docs/en/insight/features">기능</a>
<a href="https://langbot.app/docs/en/insight/guide">문서</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">기능</a>
<a href="https://link.langbot.app/en/docs/guide">문서</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Главная</a>
<a href="https://langbot.app/docs/en/insight/features">Возможности</a>
<a href="https://langbot.app/docs/en/insight/guide">Документация</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">Возможности</a>
<a href="https://link.langbot.app/en/docs/guide">Документация</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
---
+6 -6
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官網</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文件</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文件</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
@@ -90,7 +90,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
---
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|-----------|------|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Trang chủ</a>
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a>
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://link.langbot.app/en/docs/features">Tính năng</a>
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
---
+1 -1
View File
@@ -1,5 +1,5 @@
# Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
version: "3"
services:
+1 -1
View File
@@ -1,7 +1,7 @@
# Kubernetes Deployment for LangBot
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
#
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
#
# Usage:
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
-17
View File
@@ -88,23 +88,6 @@ Each endpoint accepts **either**:
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
### Inspecting API Key Identity
`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions:
```json
{
"code": 0,
"msg": "ok",
"data": {
"instance_uuid": "...",
"workspace_uuid": "...",
"api_key_id": "...",
"permissions": ["..."]
}
}
```
## Example: Model Management
### List All LLM Models
-65
View File
@@ -1,65 +0,0 @@
# ChatGPT / Codex subscription
LangBot's **OpenAI Codex** model provider uses **Sign in with ChatGPT** and the account's Codex entitlement. It is separate from the existing OpenAI API-key provider: subscribing to ChatGPT does not supply an OpenAI Platform API key, and API-key billing is unchanged.
## Connect an account
1. Open **Models**, choose **Add Provider**, and select **OpenAI Codex**.
2. Enter a provider name and choose **Save and sign in**. This saves the provider before authorization, so an interrupted login can be retried from its settings.
3. Open the OpenAI authorization link and enter the one-time code displayed in LangBot. Sign in on OpenAI's site, not in LangBot.
4. If OpenAI asks you to enable device-code authorization, enable it in your ChatGPT account's security settings, or contact your workspace administrator.
5. Keep the LangBot dialog open until it confirms the connection, then finish the form.
6. Use the existing **Scan models** or **Add model** controls, test the model, and select it in a pipeline as usual. Only LLM models are supported by this provider.
The device-code flow also works when LangBot runs remotely or in Docker: the browser does not need to reach a localhost OAuth callback on the server. Serve the LangBot management panel over HTTPS when accessing it remotely.
The account's model catalog is authoritative. A model listed elsewhere or entered manually is not a guarantee that this account has access. Scan errors are reported rather than replaced with a fabricated available-model list.
## Reconnect and disconnect
Open the provider's existing settings to sign in again or disconnect. LangBot refreshes expiring access tokens automatically. A revoked or invalid refresh grant requires another sign-in; transient network failures are not proof that the grant was revoked.
**Disconnect** removes this provider's locally stored authorization. It does not log the account out of other applications or revoke the account globally. Canceling a pending sign-in is separate from disconnecting an existing account. Removing a provider also removes its authorization; the normal rule that models must be removed first still applies.
A saved provider can remain disconnected. Scanning or invoking it then returns a sign-in-required error; LangBot does not silently switch to paid API-key billing.
## Usage and deployment boundary
Calls consume the connected account's included Codex usage and remain subject to OpenAI's plan limits, model availability, workspace policies, and terms. Token counts recorded by LangBot are request usage, not a measurement of remaining subscription quota or an OpenAI invoice.
Use this integration for your own authorized account and trusted workflows. Third-party sign-in support is not permission to pool accounts, resell subscription quota, or redistribute one subscription as a shared API service. For a public or commercial multi-user service, use the appropriate OpenAI API or separately authorized enterprise arrangement. The provider remains a Workspace resource in LangBot: consider who can invoke its models before connecting a personal account.
## Credential handling and API surface
- OAuth credentials are stored server-side separately from provider API keys. Provider and model reads do not supply OAuth access, refresh, or ID tokens.
- Authorization uses a fixed OpenAI origin. The Codex provider does not accept a custom base URL or manually supplied API keys.
- Authentication controls require an authenticated LangBot browser user with `provider_secret.manage` in the selected Workspace. Pending attempts are scoped to the Workspace, provider, and initiating user.
- Browser storage must not contain OAuth tokens. Treat the server database and its backups as sensitive application data.
- MCP and LangBot API keys do not expose the browser-only OAuth controls. Agents may inspect configured providers and models with the existing tools, but a human connects the subscription in the management panel.
The provider-scoped authentication routes are under `/api/v1/provider/providers/{uuid}/codex`:
| Method | Suffix | Purpose |
| --- | --- | --- |
| GET | `/status` | Read local connection state without returning credentials |
| POST | `/device` | Start device authorization |
| POST | `/device/poll` | Poll the initiating user's authorization attempt |
| DELETE | `/device/{authorization_id}` | Cancel only that pending attempt |
| DELETE | `/auth` | Remove local authorization |
Use the returned polling interval and expiration time. An expired attempt must be restarted. These routes are not a general-purpose subscription-to-API gateway.
## References
- [OpenAI Codex authentication](https://developers.openai.com/codex/auth): ChatGPT versus API-key access and device-code login.
- [Hermes Agent providers](https://hermes-agent.nousresearch.com/docs/integrations/providers/): subscription device authentication and refresh recovery.
- [OpenClaw OpenAI provider](https://docs.openclaw.ai/providers/openai): subscription and API-key route distinctions.
- [New API](https://github.com/QuantumNous/new-api): reference for Codex protocol compatibility; its gateway/account-pooling product model is not adopted here.
## 中文快速说明
在「模型」中添加提供商,选择 **OpenAI Codex**,填写名称并点击「保存并登录」。打开 OpenAI 授权页面,输入 LangBot 显示的一次性验证码,完成授权后回到原对话框。随后照常扫描或添加模型、测试模型,并在流水线中选择它。
无需填写 API Key,也无需为远程服务器配置 localhost 回调。登录中断后可以从该提供商的设置中重试;断开连接只删除 LangBot 中保存的授权。调用消耗所登录账号的 Codex 额度,受账号实际权限和 OpenAI 限制约束,不会自动转用按量付费的 OpenAI API。
此功能用于自己的授权账号及可信工作流,不应将个人订阅作为面向多个用户转售或共享的 API 服务。提供商仍是 LangBot 工作空间内的资源,连接个人账号前请确认模型的使用范围。
+2 -2
View File
@@ -218,8 +218,8 @@ metadata:
spec:
categories: [popular, global]
help_links:
zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
config:
- { name: inbound_secret, type: string, required: true, default: "" }
- { name: callback_url, type: string, required: false, default: "" }
+1 -1
View File
@@ -243,7 +243,7 @@ For large datasets:
- SeekDB GitHub: https://github.com/oceanbase/seekdb
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://langbot.app/docs
- LangBot Documentation: https://docs.langbot.app
## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
They show the whole loop: signing a request, pushing a message, and receiving
multi-part replies on a callback endpoint.
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
## Files
+1 -1
View File
@@ -6,7 +6,7 @@
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
1→M 的多段回复。
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
## 文件清单
+1 -1
View File
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
a single `<script>` tag.
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
## Files
+1 -1
View File
@@ -6,7 +6,7 @@
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
网站上的那个组件。
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
## 文件清单
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.10"
version = "4.10.8"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.7",
"langbot-plugin==0.5.5",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -114,7 +114,7 @@ seekdb = [
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://langbot.app/docs"
Documentation = "https://docs.langbot.app"
Repository = "https://github.com/langbot-app/LangBot"
[project.scripts]
+1 -2
View File
@@ -1349,8 +1349,7 @@
"local-agent",
"tools",
"e2b",
"nsjail",
"host"
"nsjail"
],
"automation": "",
"setup_automation": [],
+2 -8
View File
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
## Kubernetes
See `docker/kubernetes.yaml` and the deployment guide at
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
## config.yaml (generated at `data/config.yaml` on first run)
@@ -63,7 +63,7 @@ Key settings:
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
| `box.enabled` | Master switch for the Box sandbox runtime |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
@@ -75,10 +75,6 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
with `--standalone-runtime`.
- Box has a parallel `--standalone-box` flag; the Docker box host is
`langbot_box:5410`.
- `box.backend: host` runs commands directly as the Box Runtime system user.
It is never auto-selected, provides no sandbox isolation, and is only for
trusted local development. A WebSocket-controlled host backend requires
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
## Global API key — enabling for agents/automation
@@ -97,7 +93,5 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
usually means the user isn't in the `docker` group →
`sudo usermod -aG docker <user>` and restart in a new shell.
- Do not use `box.backend: host` as a production fallback. It cannot enforce
image, filesystem, network, PID, CPU, memory, or storage isolation.
- Box root host/container path mismatch breaks sandbox container creation.
- Don't commit a non-empty `api.global_api_key` to version control.
-34
View File
@@ -43,8 +43,6 @@ Two kinds of key are accepted:
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
scopes do not authorize a tool gets `403 Forbidden`.
To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key.
## Client configuration
```json
@@ -88,38 +86,6 @@ already have a default pipeline.
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
`delete_*` as needed.
## ChatGPT / Codex subscription providers
`list_model_providers` can return the `openai-codex` requester. Its OAuth
credentials are server-only and are not provider API keys. Never ask a user
to paste ChatGPT access tokens, refresh tokens, or a Codex auth cache into an
MCP tool or model configuration.
A human connects or disconnects the subscription through **Models → provider
settings** in the LangBot web UI. The provider-scoped `/codex/*` authentication
routes deliberately require a browser-user session and are not exposed as MCP
tools or authorized by a LangBot API key. Once connected, models are managed
and selected through the normal provider/model workflow. A disconnected
provider must be reauthorized; do not silently replace it with API-key billing.
See [ChatGPT / Codex subscription](../../../docs/CODEX_SUBSCRIPTION.md) for setup,
usage limits, and the personal-account versus shared-service boundary.
## Provider deletion
The curated MCP surface currently lists providers but has no provider-deletion
tool. In the web UI, **Edit Provider → Delete** asks for confirmation before
removing that provider and all its LLM, embedding, and rerank models. This is
irreversible; never interpret a request to edit a provider as authorization to
delete it.
The equivalent HTTP operation is
`DELETE /api/v1/provider/providers/{uuid}?cascade=true`, requiring
`resource.manage` in the authenticated Workspace. Omitting `cascade` preserves
the existing refusal to delete providers that still have models. Cloud-managed
providers remain protected. Cascade deletion removes stored Codex authorization
state as well; it is not the same operation as disconnecting an account.
## Implementation & maintenance (for LangBot developers)
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
@@ -13,7 +13,6 @@ tags:
- tools
- e2b
- nsjail
- host
skills:
- langbot-env-setup
- langbot-testing
@@ -24,7 +23,7 @@ env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
steps:
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
@@ -34,7 +33,7 @@ steps:
checks:
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
- "Box status: recent_error_count is 0 after the run."
evidence_required:
@@ -4,7 +4,7 @@
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
## Preconditions
@@ -13,7 +13,6 @@ This flow applies to Docker, nsjail, E2B, and the explicit host development back
- `BOX_BACKEND=e2b` when validating E2B.
- `BOX_BACKEND=nsjail` when validating nsjail.
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
4. Confirm Debug Chat uses a model with function-calling ability.
5. Confirm backend logs say native sandbox tools are available.
@@ -72,7 +71,7 @@ Backend logs should show:
- `register_skill`
- `activate`
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
- `backend=e2b`, `backend=nsjail`, or the expected local backend
After the run, verify the skill store through the UI or API:
@@ -126,8 +125,6 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
- The host backend does not honor sandbox image, network, rootfs, process, or
resource isolation. Use a disposable workspace and low-privilege account.
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
## Related Troubleshooting
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
date: 2026-05-18
symptoms:
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
patterns:
- "Native sandbox tools ... are NOT available"
@@ -19,7 +19,6 @@ fix_steps:
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
- "For E2B, verify the key without printing it and confirm any required template setting."
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
related_cases:
- sandbox-skill-authoring-e2e
+1 -1
View File
@@ -16,7 +16,7 @@ asciiart = r"""
|___/
Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://langbot.app/docs
📖 Documentation 文档地址: https://docs.langbot.app
"""
+11 -38
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
import asyncio
import json
import os
import typing
from pathlib import Path
import httpx
import typing
import json
from .errors import DifyAPIError
from pathlib import Path
import os
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
@@ -16,32 +15,6 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
data = line[5:].strip()
if not data or data == b'[DONE]':
return None
try:
payload = json.loads(data.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
if not isinstance(payload, dict):
raise DifyAPIError('Dify SSE event is not a JSON object')
return payload
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
try:
response = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise DifyAPIError('Dify upload response is not valid JSON') from exc
if not isinstance(response, dict):
raise DifyAPIError('Dify upload response is not a JSON object')
payload = response.get('data', response)
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
raise DifyAPIError('Dify upload response does not contain a valid file id')
return payload
async def _read_limited_response(
response: httpx.Response,
*,
@@ -83,16 +56,16 @@ async def _iter_sse_json(
line = raw_line.rstrip(b'\r').strip()
if not line or not line.startswith(b'data:'):
continue
payload = _decode_sse_data(line)
if payload is not None:
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
if isinstance(payload, dict):
yield payload
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
line = bytes(buffer).rstrip(b'\r').strip()
if line.startswith(b'data:'):
payload = _decode_sse_data(line)
if payload is not None:
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
if isinstance(payload, dict):
yield payload
@@ -269,7 +242,7 @@ class AsyncDifyServiceClient:
file: httpx._types.FileTypes,
user: str,
timeout: float = 30.0,
) -> dict[str, typing.Any]:
) -> str:
# 处理 Path 对象
if isinstance(file, Path):
if not file.exists():
@@ -298,6 +271,6 @@ class AsyncDifyServiceClient:
timeout=timeout,
) as response:
body = await _read_limited_response(response)
if response.status_code not in (200, 201):
if response.status_code != 201:
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
return _decode_upload_response(body)
return json.loads(body)
+2 -3
View File
@@ -697,10 +697,9 @@ class DingTalkClient:
if not await self.check_access_token():
await self.get_access_token()
template_params = dict(card_param_map or {})
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
if card_data_config is not None:
template_params['config'] = card_data_config
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
cardData['config'] = json.dumps(card_data_config)
body: dict = {
'cardTemplateId': card_template_id,
@@ -936,13 +936,6 @@ class WecomBotWsClient:
'chat_type': message_data.get('type', 'single'),
}
self._prune_stream_state()
# Send an initial empty stream frame so the WeCom client
# shows its built-in loading spinner while the pipeline
# processes the message (e.g. RAG retrieval).
try:
await self.reply_stream(req_id, stream_id, '', finish=False)
except Exception:
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
@@ -295,34 +295,6 @@ class WecomCSClient:
raise Exception('Failed to send message')
return data
@_bounded_token_retry
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
payload = {
'touser': external_userid,
'open_kfid': open_kfid,
'msgid': msgid,
'msgtype': 'image',
'image': {
'media_id': media_id,
},
}
async with self._http_client_context() as client:
response = await client.post(url, json=payload)
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image message')
return data
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request)
@@ -15,7 +15,6 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError
from ....core.errors import TaskCapacityError
from ....provider.modelmgr.codex_errors import CodexProviderError
from ..authz import (
AuthenticationDeniedError,
AuthorizationError,
@@ -248,8 +247,6 @@ class RouterGroup(abc.ABC):
return await f(*args, **kwargs)
except Exception as e: # 自动 500
if isinstance(e, CodexProviderError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, AuthorizationError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, WorkspaceNotFoundError):
@@ -218,7 +218,6 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
@@ -238,7 +237,6 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
user_query=user_query,
is_active=is_active,
limit=limit,
offset=offset,
@@ -398,14 +396,7 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session"""
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
analysis = await self.ap.monitoring_service.get_session_analysis(
request_context,
session_id,
start_time=start_time,
end_time=end_time,
)
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
# Always return success with the analysis data
# The frontend will handle the 'found: false' case
@@ -3,7 +3,6 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
from .query import resolve_include_secret
@group.group_class('models/llm', '/api/v1/provider/models/llm')
@@ -17,12 +16,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
if provider_uuid:
models = await self.ap.llm_model_service.get_llm_models_by_provider(
request_context,
@@ -59,16 +53,10 @@ class LLMModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.llm_model_service.get_llm_model(
request_context,
model_uuid,
include_secret=include_secret,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -123,12 +111,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
if provider_uuid:
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
request_context,
@@ -165,16 +148,10 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.embedding_models_service.get_embedding_model(
request_context,
model_uuid,
include_secret=include_secret,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -231,12 +208,7 @@ class RerankModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
if provider_uuid:
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
request_context,
@@ -273,16 +245,10 @@ class RerankModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.rerank_models_service.get_rerank_model(
request_context,
model_uuid,
include_secret=include_secret,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -3,86 +3,11 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
from .query import resolve_include_secret
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
# Subscription authorization is an interactive, browser-user-only surface.
@self.route(
'/<provider_uuid>/codex/status',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_status(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_device(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/poll',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_poll(provider_uuid: str, request_context: RequestContext):
body = await quart.request.get_json()
if not isinstance(body, dict):
return self.http_status(400, -1, 'JSON object required')
try:
return self.success(
data=await self.ap.provider_service.codex_auth.poll(
request_context, provider_uuid, body.get('authorization_id')
)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/auth',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_disconnect(provider_uuid: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/<authorization_id>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'',
methods=['GET'],
@@ -90,15 +15,9 @@ class ModelProvidersRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
providers = await self.ap.provider_service.get_providers(
request_context,
include_secret=include_secret,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
@@ -128,16 +47,10 @@ class ModelProvidersRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
provider = await self.ap.provider_service.get_provider(
request_context,
provider_uuid,
include_secret=include_secret,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if provider is None:
return self.http_status(404, -1, 'provider not found')
@@ -169,15 +82,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
try:
cascade_values = quart.request.args.getlist('cascade')
if cascade_values:
if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'):
return self.http_status(400, -1, 'cascade must be a single true or false value')
await self.ap.provider_service.delete_provider(
request_context, provider_uuid, cascade=cascade_values[0] == 'true'
)
else:
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
@@ -1,15 +0,0 @@
from __future__ import annotations
def resolve_include_secret(raw_value: str | None, *, permitted: bool) -> tuple[bool, str | None]:
"""Resolve the optional secret projection query parameter."""
if raw_value is None:
return permitted, None
value = raw_value.strip().lower()
if value == 'false':
return False, None
if value == 'true':
return permitted, None
return False, 'include_secret must be either true or false'
@@ -7,116 +7,14 @@ from .. import group
from .....utils import constants
from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import PrincipalType, RequestContext
from ...context import RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService
SYSTEM_CAPABILITY_OPERATIONS = (
'bot.list',
'bot.get',
'bot.create',
'bot.update',
'bot.delete',
'pipeline.list',
'pipeline.get',
'pipeline.create',
'pipeline.update',
'pipeline.delete',
'pipeline.copy',
'task.list',
'task.get',
'knowledge_base.list',
'knowledge_base.get',
'knowledge_base.create',
'knowledge_base.update',
'knowledge_base.delete',
'knowledge_base.file.list',
'knowledge_base.file.store',
'knowledge_base.file.delete',
'knowledge_base.retrieve',
'file.document.upload',
'plugin.install.github',
'plugin.install.marketplace',
'plugin.install.local',
'plugin.upgrade',
'plugin.get',
'plugin.list',
'plugin.config.get',
'plugin.config.update',
'plugin.logs',
'plugin.delete',
'provider.list',
'provider.get',
'provider.create',
'provider.update',
'provider.delete',
'provider.scan_models',
'model.llm.list',
'model.llm.get',
'model.llm.create',
'model.llm.update',
'model.llm.delete',
'model.llm.test',
'model.embedding.list',
'model.embedding.get',
'model.embedding.create',
'model.embedding.update',
'model.embedding.delete',
'model.embedding.test',
'model.rerank.list',
'model.rerank.get',
'model.rerank.create',
'model.rerank.update',
'model.rerank.delete',
'model.rerank.test',
'skill.list',
'skill.get',
'skill.create',
'skill.update',
'skill.delete',
'skill.files.list',
'skill.files.read',
'skill.files.write',
'skill.preview',
'skill.install.github',
'skill.install.upload',
'mcp_server.list',
'mcp_server.get',
'mcp_server.create',
'mcp_server.update',
'mcp_server.delete',
'mcp_server.resources',
'mcp_server.resource_templates',
'mcp_server.resource_read',
'mcp_server.logs',
'mcp_server.test',
)
@group.group_class('system', '/api/v1/system')
class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _(request_context: RequestContext) -> str:
return self.success(
data={
'instance_uuid': request_context.instance_uuid,
'workspace_uuid': request_context.workspace_uuid,
'api_key_id': request_context.principal.api_key_uuid,
'permissions': sorted(request_context.workspace.permissions),
}
)
@self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _() -> str:
return self.success(
data={
'schema_version': 1,
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
}
)
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Read wizard_status and wizard_progress from metadata table
@@ -325,7 +223,7 @@ class SystemRouterGroup(group.RouterGroup):
@self.route(
'/tasks',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
@@ -344,23 +242,18 @@ class SystemRouterGroup(group.RouterGroup):
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
public=request_context.principal.principal_type == PrincipalType.API_KEY,
)
)
@self.route(
'/tasks/<task_id>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(task_id: str, request_context: RequestContext) -> str:
try:
task_index = int(task_id)
except (TypeError, ValueError):
return self.http_status(404, 404, 'Task not found')
task = self.ap.task_mgr.get_task_by_id(
task_index,
int(task_id),
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
@@ -369,8 +262,6 @@ class SystemRouterGroup(group.RouterGroup):
if task is None:
return self.http_status(404, 404, 'Task not found')
if request_context.principal.principal_type == PrincipalType.API_KEY:
return self.success(data=task.to_public_dict())
return self.success(data=task.to_dict())
@self.route(
@@ -2,8 +2,6 @@ import quart
import argon2
import asyncio
import datetime
import hmac
import time
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -13,33 +11,6 @@ from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
# The admission check and slot bump share ONE synchronous critical section with no await
# points, so concurrent bursts within a single event loop cannot slip past accounting.
# Every admitted attempt consumes quota (regardless of success), which throttles both the
# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.
# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.
_MAX_RESET_ATTEMPTS_PER_WINDOW = 5
_RESET_WINDOW_SECONDS = 15 * 60
_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0}
def _admit_reset_attempt(now: float) -> bool:
"""Atomically reserve one reset-password admission slot.
Must stay await-free: running to completion without suspension makes the
check-and-increment atomic under the single-threaded event loop.
"""
st = _reset_password_state
if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS:
st['window_started_at'] = now
st['attempts'] = 0
if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW:
return False
st['attempts'] += 1
return True
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@@ -110,12 +81,6 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Admit (or reject) BEFORE touching the body or any service call (#2392):
# rejecting requests never reach the slow path, and quota accounting happens
# synchronously at entry, closing the post-await race of burst requests.
if not _admit_reset_attempt(time.monotonic()):
return self.http_status(429, -1, 'Too many attempts, try again later')
json_data = await quart.request.json
user_email = json_data['user']
@@ -133,18 +98,7 @@ class UserRouterGroup(group.RouterGroup):
if user_obj is None:
return self.http_status(400, -1, 'User not found')
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if not key_matches:
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
@@ -232,9 +186,6 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback'
)
launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid')
@@ -248,11 +199,8 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing authorization code')
if not state:
return self.fail(1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.fail(1, 'Unsupported Space OAuth code contract')
try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid
@@ -270,36 +218,24 @@ class UserRouterGroup(group.RouterGroup):
code,
workspace_uuids,
workspace_created_ats,
redirect_uri=redirect_uri,
)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
return self.fail(1, 'Space OAuth Workspace binding mismatch')
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
if cloud_mode:
if not target_workspace_uuid:
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
# Authenticate only after the signed, exact Workspace delta has
# established the Account and membership runtime shadow rows.
# Authenticate and create/update local user
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in
)
if target_workspace_uuid:
if launch_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid,
target_workspace_uuid,
launch_workspace_uuid,
)
except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
@@ -431,17 +367,12 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
)
if not code:
return self.http_status(400, -1, 'Missing authorization code')
if not state:
return self.http_status(400, -1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
try:
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
@@ -454,10 +385,7 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
updated_user = await self.ap.user_service.bind_space_account(
user_obj.user, code, redirect_uri=redirect_uri
)
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success(
data={
@@ -500,10 +428,6 @@ class UserRouterGroup(group.RouterGroup):
}
)
projection_service = self.ap.directory_projection_service
if projection_service is None:
raise SpaceLaunchError('Cloud directory projection is unavailable')
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core')
+1 -10
View File
@@ -137,16 +137,7 @@ class BotService:
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
try:
await self.ap.platform_mgr.load_bot(context, bot)
except Exception:
# The bot row was already inserted above; without this rollback a
# failing adapter constructor (e.g. a missing optional credential
# key) would leave a permanently disabled orphan bot in the DB.
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
)
raise
await self.ap.platform_mgr.load_bot(context, bot)
return bot_data['uuid']
+9 -16
View File
@@ -446,19 +446,15 @@ class MCPService:
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
try:
needs_start = (
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
)
if needs_start:
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
else:
try:
await persisted_session.refresh()
except Exception:
await persisted_session.start()
else:
try:
await persisted_session.refresh()
except Exception:
await persisted_session.start()
finally:
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
coroutine = _refresh_and_report()
else:
@@ -475,11 +471,8 @@ class MCPService:
async def _run_and_cleanup() -> None:
try:
await test_session.start()
finally:
# start() raises for a failed connection. Preserve the
# terminal runtime state so the UI can render actionable
# failure phases such as OAuth-required.
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
finally:
try:
await test_session.shutdown()
except Exception as exc:
+4 -20
View File
@@ -1257,7 +1257,6 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None,
limit: int = 100,
offset: int = 0,
@@ -1275,14 +1274,6 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
sqlalchemy.or_(
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
)
)
if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1374,8 +1365,6 @@ class MonitoringService:
self,
context: TenantContext,
session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context)
@@ -1489,17 +1478,12 @@ class MonitoringService:
)
)
tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(*tool_conditions)
.where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1)
)
+50 -129
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import uuid
import traceback
@@ -8,10 +7,8 @@ import sqlalchemy
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app
from ....core.task_boundary import create_detached_task
from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError
from ....provider.modelmgr.codex_auth import CodexAuth, REQUESTER as CODEX_REQUESTER, validate_config
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
@@ -23,8 +20,6 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self.codex_auth = CodexAuth(ap)
self._deletion_tasks: set[asyncio.Task[None]] = set()
def _is_cloud_runtime(self) -> bool:
mode = getattr(self.ap.persistence_mgr, 'mode', None)
@@ -121,30 +116,14 @@ class ModelProviderService:
provider_data = provider_data.copy()
if self._system_requester_is_reserved(provider_data.get('requester')):
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
validate_config(provider_data)
provider_data['uuid'] = str(uuid.uuid4())
provider_data['workspace_uuid'] = require_workspace_uuid(context)
provider_data['api_keys'] = self._normalize_api_keys(
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
)
if provider_data.get('requester') == CODEX_REQUESTER:
async with self.ap.persistence_mgr.tenant_uow(provider_data['workspace_uuid']):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.CodexCredential).values(
workspace_uuid=provider_data['workspace_uuid'],
provider_uuid=provider_data['uuid'],
payload={},
version=0,
lease_until=0,
)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
@@ -159,17 +138,6 @@ class ModelProviderService:
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
provider_data.pop('uuid', None)
provider_data.pop('workspace_uuid', None)
if {'requester', 'base_url', 'api_keys'} & provider_data.keys():
current = await self.get_provider(context, provider_uuid, include_secret=True)
if current is None:
raise WorkspaceNotFoundError('Provider not found')
if CODEX_REQUESTER in (current.get('requester'), provider_data.get('requester')):
if provider_data.get('requester', current.get('requester')) != current.get('requester'):
raise ValueError('Create a separate provider to change the ChatGPT authentication type')
merged = {**current, **provider_data}
validate_config(merged)
provider_data['base_url'] = merged['base_url']
provider_data['api_keys'] = []
if 'api_keys' in provider_data:
submitted_keys = provider_data.get('api_keys')
if contains_secret_placeholder(submitted_keys, sensitive=True):
@@ -195,107 +163,60 @@ class ModelProviderService:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.reload_provider(context, provider_uuid)
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
await self._assert_provider_mutable(context, provider_uuid)
workspace_uuid = require_workspace_uuid(context)
persistence = self.ap.persistence_mgr
model_types = (
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
),
persistence_model.LLMModel,
workspace_uuid,
)
)
deleted_models: list[tuple[str, list[str]]] = []
async with persistence.tenant_uow(workspace_uuid):
# Check ownership before touching children. Lock the provider on PostgreSQL
# so concurrent model inserts cannot race the reference check/deletion.
provider_result = await persistence.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider.requester)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.with_for_update(),
persistence_model.ModelProvider,
workspace_uuid,
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
),
persistence_model.EmbeddingModel,
workspace_uuid,
)
provider = provider_result.first()
if provider is None:
raise WorkspaceNotFoundError('Provider not found')
if self._system_requester_is_reserved(provider.requester):
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
for model_type, label, remover in model_types:
result = await persistence.execute_async(
scope_statement(
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
model_type,
workspace_uuid,
)
)
model_uuids = list(result.scalars())
if model_uuids and not cascade:
raise ValueError(f'Cannot delete provider: {label} models still reference it')
if model_uuids:
# Model services have no pipeline/KB deletion side effects: they
# delete the scoped row and evict its runtime cache. Defer eviction
# here rather than calling those services before our commit.
await persistence.execute_async(
scope_statement(
sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid),
model_type,
workspace_uuid,
)
)
deleted_models.append((remover, model_uuids))
# Explicit cleanup also works on legacy SQLite connections without FK
# enforcement; never load or serialize the private credential payload.
await persistence.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.CodexCredential).where(
persistence_model.CodexCredential.provider_uuid == provider_uuid
),
persistence_model.CodexCredential,
workspace_uuid,
)
rerank_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
),
persistence_model.RerankModel,
workspace_uuid,
)
result = await persistence.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
)
if result.rowcount == 0:
raise WorkspaceNotFoundError('Provider not found')
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Provider not found')
async def remove_runtime() -> None:
async with persistence.tenant_scope(workspace_uuid):
for remover, model_uuids in deleted_models:
for model_uuid in model_uuids:
await getattr(self.ap.model_mgr, remover)(context, model_uuid)
# This also closes the requester's HTTP client; models go first.
await self.ap.model_mgr.remove_provider(context, provider_uuid)
if persistence.current_session() is None:
await remove_runtime()
else:
# A nested UoW has not committed yet. Reuse the rollback-cancelled gate
# and detached context boundary instead of evicting uncommitted data.
task = create_detached_task(
remove_runtime(),
after_commit_manager=persistence,
workspace_uuid=workspace_uuid,
)
self._deletion_tasks.add(task)
def completed(task: asyncio.Task[None]) -> None:
self._deletion_tasks.discard(task)
if not task.cancelled() and task.exception() is not None:
self.ap.logger.error('Failed to remove deleted provider runtime', exc_info=task.exception())
task.add_done_callback(completed)
await self.ap.model_mgr.remove_provider(context, provider_uuid)
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
+1 -4
View File
@@ -119,7 +119,7 @@ class SpaceService:
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
params = {'redirect_uri': redirect_uri}
if state:
params['state'] = state
return f'{authorize_url}?{urlencode(params)}'
@@ -129,8 +129,6 @@ class SpaceService:
code: str,
workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None,
*,
redirect_uri: str = '',
) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
@@ -143,7 +141,6 @@ class SpaceService:
f'{space_url}/api/v1/accounts/oauth/token',
json={
'code': code,
'redirect_uri': redirect_uri,
'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding.
+2 -3
View File
@@ -774,7 +774,7 @@ class UserService:
f'email:{normalized_email}',
)
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email)
if local_account is None:
@@ -794,13 +794,12 @@ class UserService:
code,
[binding.workspace_uuid],
{binding.workspace_uuid: created_ts},
redirect_uri=redirect_uri,
)
else:
# Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
+3 -10
View File
@@ -455,9 +455,7 @@ class BoxService:
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
if not self._available:
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
if self._cloud_managed:
if self._admission is None:
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
@@ -567,9 +565,7 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
if not self._available:
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
await self._require_validated_workspace_sandbox(execution_context)
@@ -2146,8 +2142,5 @@ class BoxService:
if backend_name:
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
else:
payload['connector_error'] = (
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
'Trusted local development may explicitly select the unsafe host backend.'
)
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
return payload
+2 -100
View File
@@ -125,21 +125,10 @@ class DirectoryProjectionService:
# The database cursor remains the shared projection high-water mark,
# while this cursor tracks what this process has actually observed.
self._consumer_cursor: int | None = None
self._sync_lock = asyncio.Lock()
async def initialize(self) -> None:
"""Block Cloud startup until one full signed snapshot is committed."""
async with self._sync_lock:
await self._refresh_snapshot()
async def refresh_snapshot(self) -> None:
"""Refresh from one full signed snapshot within the sync single-flight."""
async with self._sync_lock:
await self._refresh_snapshot()
async def _refresh_snapshot(self) -> None:
last_superseded: _DirectorySnapshotSuperseded | None = None
for _attempt in range(5):
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
@@ -170,84 +159,9 @@ class DirectoryProjectionService:
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
async def sync_once(self) -> None:
async with self._sync_lock:
await self._sync_once()
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
"""Synchronously project an exact Workspace set without moving the event cursor."""
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
if not requested:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
if len(requested) > self.event_limit:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
async with self._sync_lock:
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
await self._apply_targeted_delta(delta, requested)
async def _apply_targeted_delta(
self,
delta: DirectoryDelta,
requested_workspace_uuids: tuple[str, ...],
) -> None:
if not isinstance(delta, DirectoryDelta):
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
workspace_count, membership_count = self._validate_batch_capacity(
delta.workspaces,
full_snapshot=False,
)
delta = DirectoryDelta.model_validate(delta.model_dump())
if delta.instance_uuid != self.instance_uuid:
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
requested = set(requested_workspace_uuids)
if set(delta.requested_workspace_uuids) != requested:
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
if {workspace.uuid for workspace in delta.workspaces} != requested:
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
if not callable(directory_uow):
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
async with directory_uow(self.instance_uuid) as uow:
session = uow.session
state = await session.scalar(
sqlalchemy.select(DirectoryProjectionState)
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
.with_for_update()
)
if state is None:
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
snapshot = DirectorySnapshot(
instance_uuid=self.instance_uuid,
cursor=state.cursor,
generated_at=delta.generated_at,
workspaces=delta.workspaces,
)
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
active_workspace_count = await self._enforce_active_workspace_capacity(session)
await session.flush()
await self._update_entitlement_workspace_activity(
snapshot.workspaces,
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
snapshot.workspaces,
affected_workspace_uuids=requested,
)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
memberships=membership_count,
)
async def _sync_once(self) -> None:
cursor = self._consumer_cursor
if cursor is None:
await self._refresh_snapshot()
await self.initialize()
return
batch = await self.provider.fetch_events(
self.instance_uuid,
@@ -794,13 +708,7 @@ class DirectoryProjectionService:
for row in inbox_rows:
row.applied_at = now
async def _apply_accounts(
self,
session: Any,
snapshot: DirectorySnapshot,
*,
preserve_existing: bool = False,
) -> dict[str, User]:
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
selected: dict[str, DirectoryMember] = {}
emails: dict[str, str] = {}
for workspace in snapshot.workspaces:
@@ -865,12 +773,6 @@ class DirectoryProjectionService:
continue
if account.source != AccountSource.CLOUD_PROJECTION.value:
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
if preserve_existing:
# A targeted Workspace fetch has no independently monotonic
# Account revision. It may create a missing runtime shadow, but
# ordered event/snapshot projection remains the only updater of
# existing Account identity and status fields.
continue
if account.projection_revision > snapshot.cursor:
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
projected_account = self._account_projection(member)
+2 -2
View File
@@ -635,9 +635,9 @@ class Application:
frontend_path = paths.get_frontend_path()
if not os.path.exists(frontend_path):
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
self.logger.warning(
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
)
return
+1 -20
View File
@@ -1,18 +1,9 @@
from __future__ import annotations
import logging
import secrets
from .. import stage, app
# This stage runs before SetupLoggerStage, so ap.logger is still None here;
# the module logger falls back to the stderr lastResort handler.
_logger = logging.getLogger(__name__)
# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits.
_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
_RECOVERY_KEY_LENGTH = 8
@stage.stage_class('GenKeysStage')
class GenKeysStage(stage.BootingStage):
@@ -29,15 +20,5 @@ class GenKeysStage(stage.BootingStage):
ap.instance_config.data['system']['recovery_key'] = ''
if not ap.instance_config.data['system']['recovery_key']:
# Keep recovery practical to type. Security also requires the reset
# endpoint's concurrency-safe quota (five admissions per 15 minutes).
ap.instance_config.data['system']['recovery_key'] = ''.join(
secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH)
)
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
await ap.instance_config.dump_config()
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
_logger.warning(
'Low-entropy legacy recovery key detected (length < 8); '
'regenerate system.recovery_key in the configuration file '
'with a strong random value (#2392)'
)
+12 -49
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import json
import typing
import datetime
import time
@@ -198,41 +197,6 @@ class TaskWrapper:
},
}
def to_public_dict(self) -> dict:
"""Return the stable task projection exposed to API-key callers."""
if self.task.cancelled():
status = 'cancelled'
error = {'type': 'task_cancelled', 'message': 'Task was cancelled'}
result = None
elif not self.task.done():
status = 'running'
error = None
result = None
else:
exception = self.assume_exception()
if exception is not None:
status = 'failed'
error = {'type': 'task_failed', 'message': 'Task execution failed'}
result = None
else:
status = 'succeeded'
error = None
result = self.assume_result()
try:
json.dumps(result)
except (TypeError, ValueError):
result = None
return {
'id': self.id,
'task_type': self.task_type,
'kind': self.kind,
'status': status,
'error': error,
'result': result,
'created_at': self.created_at,
}
def cancel(self):
self.task.cancel()
@@ -361,20 +325,19 @@ class AsyncTaskManager:
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
public: bool = False,
) -> dict:
tasks = [
t.to_public_dict() if public else t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
]
if public:
return {'tasks': tasks}
return {'tasks': tasks, 'id_index': TaskWrapper._id_index}
return {
'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
],
'id_index': TaskWrapper._id_index,
}
def get_stats(self) -> dict:
completed = sum(1 for t in self.tasks if t.task.done())
@@ -33,28 +33,6 @@ class ModelProvider(Base):
)
class CodexCredential(Base):
"""Server-only OAuth state. Never joined into provider/model serialization."""
__tablename__ = 'codex_credentials'
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
payload = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
lease_owner = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
lease_until = sqlalchemy.Column(sqlalchemy.Float, nullable=False, default=0)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_codex_credentials_workspace_provider',
ondelete='CASCADE',
),
sqlalchemy.Index('ix_codex_credentials_workspace', 'workspace_uuid'),
)
class LLMModel(Base):
"""LLM model"""
@@ -1,48 +0,0 @@
"""Add isolated server-only Codex credentials and tenant RLS.
Revision ID: 0022_codex_credentials
Revises: 0021_merge_reasoning_config
"""
from alembic import op
import sqlalchemy as sa
revision = '0022_codex_credentials'
down_revision = '0021_merge_reasoning_config'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Fresh startup creates ORM metadata before running Alembic.
if 'codex_credentials' not in sa.inspect(conn).get_table_names():
op.create_table(
'codex_credentials',
sa.Column('provider_uuid', sa.String(255), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('payload', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('lease_owner', sa.String(64), nullable=True),
sa.Column('lease_until', sa.Float(), nullable=False),
sa.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_codex_credentials_workspace_provider',
ondelete='CASCADE',
),
)
op.create_index('ix_codex_credentials_workspace', 'codex_credentials', ['workspace_uuid'])
if conn.dialect.name == 'postgresql':
op.execute('ALTER TABLE codex_credentials ENABLE ROW LEVEL SECURITY')
op.execute('ALTER TABLE codex_credentials FORCE ROW LEVEL SECURITY')
op.execute('DROP POLICY IF EXISTS langbot_workspace_isolation ON codex_credentials')
expression = "workspace_uuid::text = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
op.execute(
f'CREATE POLICY langbot_workspace_isolation ON codex_credentials '
f'FOR ALL USING ({expression}) WITH CHECK ({expression})'
)
def downgrade() -> None:
op.drop_table('codex_credentials')
-1
View File
@@ -62,7 +62,6 @@ _ALEMBIC_TENANT_TABLES = {
'binary_storages',
'mcp_servers',
'model_providers',
'codex_credentials',
'llm_models',
'embedding_models',
'rerank_models',
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import datetime
import json
@@ -83,7 +82,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
with contextlib.closing(_open_read_only(path)) as connection:
with _open_read_only(path) as connection:
_verify_connection(connection, expected_revision)
@@ -120,16 +119,12 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
"""Sync a file, tolerating delayed visibility after replace on bind mounts.
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
access to the file descriptor).
"""
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
descriptor: int | None = None
for attempt in range(reopen_attempts):
try:
descriptor = os.open(path, os.O_RDWR)
descriptor = os.open(path, os.O_RDONLY)
break
except FileNotFoundError:
if attempt + 1 >= reopen_attempts:
@@ -143,37 +138,13 @@ def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
def _fsync_directory(path: pathlib.Path) -> None:
if os.name == 'nt':
# Windows cannot fsync directory handles opened through os.open.
return
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _remove_stale_temporary_files(
directory: pathlib.Path,
*,
prefix: str,
suffix: str,
) -> None:
"""Remove temporary files left by an interrupted backup or restore."""
for candidate in directory.iterdir():
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
continue
try:
candidate.unlink()
except FileNotFoundError:
continue
except PermissionError:
# Another process may still own this file. Do not turn harmless
# cleanup into a migration failure; its unique name cannot collide.
continue
def _create_backup(
database_path: pathlib.Path,
source_revision: str,
@@ -182,11 +153,6 @@ def _create_backup(
backup_directory = database_path.parent / 'migration-backups'
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_directory, 0o700)
_remove_stale_temporary_files(
backup_directory,
prefix=f'.{database_path.stem}-pre-',
suffix='.creating',
)
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
stem = (
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
@@ -203,8 +169,11 @@ def _create_backup(
temporary_path = pathlib.Path(temporary_name)
try:
with (
contextlib.closing(_open_read_only(database_path)) as source,
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
_open_read_only(database_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.execute('PRAGMA busy_timeout = 30000')
source.backup(destination)
@@ -252,11 +221,6 @@ async def create_verified_backup(
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
_verify_file(backup.backup_path, backup.source_revision)
_remove_stale_temporary_files(
backup.database_path.parent,
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
@@ -266,8 +230,11 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
temporary_path = pathlib.Path(temporary_name)
try:
with (
contextlib.closing(_open_read_only(backup.backup_path)) as source,
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
_open_read_only(backup.backup_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.backup(destination)
destination.commit()
+2 -34
View File
@@ -51,7 +51,6 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'binary_storages': 'workspace_uuid',
'mcp_servers': 'workspace_uuid',
'model_providers': 'workspace_uuid',
'codex_credentials': 'workspace_uuid',
'llm_models': 'workspace_uuid',
'embedding_models': 'workspace_uuid',
'rerank_models': 'workspace_uuid',
@@ -210,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
_ALLOWED_SCOPED_STATEMENT_TYPES = (
sqlalchemy.sql.dml.UpdateBase,
@@ -853,30 +852,7 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
self._require_owner_task()
self._enter_internal_access()
try:
# Retain the actual connection before COMMIT: after a failed SQLite
# COMMIT the logical transaction is inactive, but the DBAPI writer
# can still hold PENDING/RESERVED locks. Session.close()/rollback()
# alone can then return that poisoned connection to the pool.
connection = await super().connection()
try:
await transaction.commit()
except BaseException as exc:
cleanup = asyncio.create_task(connection.invalidate())
# Invalidation does not access the task-owned Session. Shield
# physical cleanup, including against repeated cancellation,
# before the owner closes the Session and releases its scope.
while not cleanup.done():
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
continue
except BaseException:
break
try:
cleanup.result()
except BaseException as cleanup_error:
exc.add_note(f'Failed to invalidate transaction connection: {cleanup_error!r}')
raise
await transaction.commit()
finally:
self._exit_internal_access()
@@ -1393,7 +1369,6 @@ class TenantUnitOfWork:
state.mark_rollback_only(exc_value)
rollback_only = state.rollback_only
committed = False
transaction_error: BaseException | None = None
try:
if exc_type is None and not rollback_only:
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
@@ -1406,9 +1381,6 @@ class TenantUnitOfWork:
_UOW_SESSION_CONTROL_CAPABILITY,
transaction,
)
except BaseException as exc:
transaction_error = exc
raise
finally:
try:
if self._active_transaction is not None and self._context_token is not None:
@@ -1416,10 +1388,6 @@ class TenantUnitOfWork:
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
_UOW_SESSION_CONTROL_CAPABILITY
)
except BaseException as cleanup_error:
if transaction_error is None:
raise
transaction_error.add_note(f'Failed to close transaction Session: {cleanup_error!r}')
finally:
if self._database_operation_token is not None:
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
@@ -5,11 +5,6 @@ from .. import entities
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from ....utils.safe_regex import SafeRegexError, mask_patterns
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
# default safe_regex per-call cap of 64 and used to fail-close every message.
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
_MAX_SENSITIVE_WORD_PATTERNS = 256
@filter_model.filter_class('ban-word-filter')
class BanWordFilter(filter_model.ContentFilter):
@@ -19,17 +14,12 @@ class BanWordFilter(filter_model.ContentFilter):
pass
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
words = self.ap.sensitive_meta.data.get('words') or []
mask = self.ap.sensitive_meta.data['mask']
mask_word = self.ap.sensitive_meta.data['mask_word']
try:
found, current = await mask_patterns(
words,
found, message = await mask_patterns(
self.ap.sensitive_meta.data['words'],
message,
mask=mask,
mask_word=mask_word,
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
mask=self.ap.sensitive_meta.data['mask'],
mask_word=self.ap.sensitive_meta.data['mask_word'],
)
except SafeRegexError as exc:
return entities.FilterResult(
@@ -41,7 +31,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult(
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
replacement=current,
replacement=message,
user_notice='消息中存在不合适的内容, 请修改' if found else '',
console_notice='',
)
@@ -15,9 +15,9 @@ spec:
categories:
- protocol
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
zh: https://link.langbot.app/zh/platforms/aiocqhttp
en: https://link.langbot.app/en/platforms/aiocqhttp
ja: https://link.langbot.app/ja/platforms/aiocqhttp
config:
- name: host
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
en: https://langbot.app/docs/en/usage/platforms/dingtalk
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
zh: https://link.langbot.app/zh/platforms/dingtalk
en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk
config:
- name: one-click-create
label:
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/discord
en: https://langbot.app/docs/en/usage/platforms/discord
ja: https://langbot.app/docs/ja/usage/platforms/discord
zh: https://link.langbot.app/zh/platforms/discord
en: https://link.langbot.app/en/platforms/discord
ja: https://link.langbot.app/ja/platforms/discord
config:
- name: client_id
label:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

-587
View File
@@ -1,587 +0,0 @@
"""ESPL V3 adapter — WebSocket server for E-SP-Line2's Adapter Gateway (接入器).
LangBot acts as a server-mode WebSocket endpoint. E-SP-Line2's adapter (接入器)
in **client mode** connects to this endpoint (or a reverse proxy forwards it)
and exchanges e-commerce messages:
* **Inbound** E-SP-Line2 broadcasts ``message.received`` envelopes to every
connected adapter client. This adapter converts each envelope into a LangBot
``FriendMessage`` / ``GroupMessage`` event (the ``conversation_id`` maps to
the LangBot launcher/session id) and fires it into the normal pipeline.
* **Outbound** every ``reply_message`` / ``reply_message_chunk`` the pipeline
emits is converted into an ESPL v3 outbound ``message`` frame
(``command_type: send_text``) and sent back over the WebSocket that carries
the matching conversation.
Design notes:
* Listens on ``ws://<host>:<port>/ws`` (default ``ws://127.0.0.1:8000/ws``).
In E-SP-Line2 create a **client-mode** 接入器 with ``ws_url`` pointing here.
* Supports multiple simultaneous E-SP-Line2 connections. Each connection is
identified by its ``adapter_id`` (from the ``key``/path) so outbound replies
route back to the correct connection.
* Heartbeats: responds to ``ping`` frames with ``pong``; the E-SP-Line2
gateway also sends server pings that we answer automatically via the
websockets library.
* The ``conversation_id`` from the inbound envelope is used as the LangBot
launcher id so each e-commerce conversation maps 1:1 to an isolated LangBot
session. Replies are routed back to the same ``conversation_id``.
* ``instance_id`` is captured from the inbound envelope and stashed on the
event's ``source_platform_object``.
See docs/user-guide/adapter-gateway.md in the E-SP-Line2 repo for the full
ESPL v3 protocol reference.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import typing
import uuid
from datetime import datetime
import pydantic
import websockets
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
logger = logging.getLogger(__name__)
# Default listen host / port (E-SP-Line2 client-mode 接入器 connects here).
_DEFAULT_HOST = '127.0.0.1'
_DEFAULT_PORT = 8000
# Default heartbeat ping interval (seconds).
_DEFAULT_HEARTBEAT_INTERVAL = 30
# Max inbound frame size (1MB, matches E-SP-Line2 gateway).
_MAX_MESSAGE_SIZE = 1 * 1024 * 1024
class _EsplConnection:
"""A single connected E-SP-Line2 adapter gateway client.
Holds the WebSocket plus the routing info needed to reply.
"""
def __init__(self, ws, adapter_id: str = ''):
self.ws = ws
self.adapter_id = adapter_id
self.send_lock = asyncio.Lock()
async def send_frame(self, frame: dict) -> None:
async with self.send_lock:
await self.ws.send(json.dumps(frame, ensure_ascii=False))
class EsplAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""ESPL V3 WebSocket server adapter (LangBot is the server)."""
bot_uuid: str = pydantic.Field(default='', exclude=True)
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = pydantic.Field(default_factory=dict, exclude=True)
# WebSocket server state (excluded from pydantic serialization).
server: typing.Any = pydantic.Field(default=None, exclude=True)
running: bool = pydantic.Field(default=False, exclude=True)
connections: dict[str, '_EsplConnection'] = pydantic.Field(default_factory=dict, exclude=True)
inbound_tasks: set[asyncio.Task] = pydantic.Field(default_factory=set, exclude=True)
heartbeat_task: asyncio.Task | None = pydantic.Field(default=None, exclude=True)
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(config=config, logger=logger, **kwargs)
self.bot_account_id = 'espl'
self.listeners = {}
self.server = None
self.running = False
self.connections = {}
self.inbound_tasks = set()
self.heartbeat_task = None
# -- framework hooks ------------------------------------------------------
def set_bot_uuid(self, bot_uuid: str) -> None:
"""Called by the bot manager so the adapter knows its own bot uuid."""
object.__setattr__(self, 'bot_uuid', bot_uuid)
def get_launcher_id(self, event: platform_events.MessageEvent) -> str:
"""Map an inbound event to a LangBot launcher id.
We use the e-commerce ``conversation_id`` (stashed on the sender id at
inbound time) so each conversation maps 1:1 to an isolated LangBot
session.
"""
if isinstance(event, platform_events.GroupMessage):
return str(event.sender.group.id)
return str(event.sender.id)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners[event_type] = func
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners.pop(event_type, None)
async def is_muted(self, group_id: int) -> bool:
return False
async def is_stream_output_supported(self) -> bool:
return False
# -- server lifecycle -----------------------------------------------------
async def run_async(self):
"""Start the WebSocket server and serve forever."""
host = str(self.config.get('host', _DEFAULT_HOST))
port = int(self.config.get('port', _DEFAULT_PORT))
self.running = True
self.server = await websockets.serve(
self._handle_connection,
host,
port,
ping_interval=None, # we manage heartbeats ourselves
max_size=_MAX_MESSAGE_SIZE,
)
await self.logger.info(f'ESPL adapter listening on ws://{host}:{port}/ws')
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
# Serve forever; run_async is expected to stay alive.
while self.running:
await asyncio.sleep(3600)
except asyncio.CancelledError:
raise
finally:
if self.server is not None:
self.server.close()
await self.server.wait_closed()
self.server = None
async def kill(self) -> bool:
"""Stop the server and close all connections."""
self.running = False
if self.heartbeat_task is not None and not self.heartbeat_task.done():
self.heartbeat_task.cancel()
self.heartbeat_task = None
for task in list(self.inbound_tasks):
if not task.done():
task.cancel()
self.inbound_tasks.clear()
for conn in list(self.connections.values()):
try:
await conn.ws.close()
except Exception:
pass
self.connections.clear()
return True
# -- connection handler ---------------------------------------------------
async def _handle_connection(self, ws):
"""Handle a new WebSocket connection from an E-SP-Line2 gateway client.
The E-SP-Line2 client-mode adapter connects with ``?key=<KEY>`` in the
query string. If the adapter has been configured with a non-empty
``key``, this method **rejects** connections that do not present a
matching key (close code 1008 policy violation).
Note: websockets >= 14 removed the ``path`` / ``query_string``
attributes from the connection object. The request path (including
the query string) is available via ``ws.request.path``.
"""
# In websockets >= 14 the request path (with query string) lives on
# ``ws.request.path`` (e.g. ``/ws?key=abc``). Fall back to the legacy
# ``ws.path`` / ``ws.query_string`` attributes for older versions.
request = getattr(ws, 'request', None)
if request is not None:
raw_path = str(getattr(request, 'path', '') or '')
else:
raw_path = str(getattr(ws, 'path', '') or '')
path, _, query = raw_path.partition('?')
# ── Key authentication ──────────────────────────────────────────
expected_key = str(self.config.get('key') or '')
provided_key = self._extract_key(query)
if expected_key:
if not provided_key:
await self.logger.warning(
f'ESPL adapter key missing; closing connection from {raw_path}'
)
await ws.close(1008, 'Unauthorized: key missing')
return
if provided_key != expected_key:
await self.logger.warning(
f'ESPL adapter key mismatch; closing connection from {raw_path}'
)
await ws.close(1008, 'Unauthorized: invalid key')
return
# ── Identify the connection for routing ─────────────────────────
adapter_id = self._extract_adapter_id(path, query)
conn = _EsplConnection(ws, adapter_id=adapter_id)
conn_key = adapter_id or ('conn_' + uuid.uuid4().hex)
self.connections[conn_key] = conn
await self.logger.info(
f'ESPL adapter client connected: adapter_id={adapter_id or "(client-mode, no adapter-id in path)"} '
f'path={raw_path}'
)
# ── Send the connected handshake ────────────────────────────────
try:
await conn.send_frame(
{
'type': 'connected',
'id': uuid.uuid4().hex,
'timestamp': int(time.time() * 1000),
'adapter_id': adapter_id or '',
'gateway_version': 'v3',
'session_id': conn_key,
'adapter_name': self.config.get('name', 'ESPL'),
'platform': self.config.get('platform', ''),
}
)
except Exception as e:
await self.logger.warning(f'ESPL adapter handshake failed: {e}')
self.connections.pop(conn_key, None)
return
# ── Read loop ───────────────────────────────────────────────────
try:
async for raw in ws:
try:
frame = json.loads(raw)
except (json.JSONDecodeError, ValueError):
await self.logger.warning(f'ESPL adapter received non-JSON frame: {raw[:200]}')
continue
await self._handle_frame(conn, frame)
except websockets.exceptions.ConnectionClosed as e:
await self.logger.info(f'ESPL adapter client disconnected: {e.code} {e.reason}')
except asyncio.CancelledError:
raise
except Exception as e:
await self.logger.warning(f'ESPL adapter connection error: {e}')
finally:
self.connections.pop(conn_key, None)
@staticmethod
def _extract_adapter_id(path: str, query: str) -> str:
"""Extract the adapter id from the connection path.
E-SP-Line2 client mode may connect to /ws/adapter-gateway/<id>?key=...
or a custom path /custom?key=... The adapter id is extracted from the
path segment, NOT from the key query parameter.
"""
path_part = path.split('?', 1)[0]
if '/ws/adapter-gateway/' in path_part:
maybe_id = path_part.rsplit('/', 1)[-1]
if maybe_id and maybe_id not in ('ws', 'adapter-gateway'):
return maybe_id
# No adapter id in the path; return empty string (anonymous connection).
return ''
@staticmethod
def _extract_key(query: str) -> str:
"""Extract the ``key`` query parameter from the WebSocket query string.
E-SP-Line2 client-mode adapter passes the access key as
``?key=<KEY>`` in the WebSocket URL (see ``client_connector.go``
line 172-177).
"""
for pair in query.split('&'):
if '=' in pair:
k, v = pair.split('=', 1)
if k == 'key':
return v
return ''
async def _handle_frame(self, conn: _EsplConnection, frame: dict) -> None:
"""Handle a single inbound frame from an E-SP-Line2 gateway client."""
msg_type = frame.get('type', '')
if msg_type == 'ping':
await conn.send_frame({'type': 'pong', 'timestamp': int(time.time() * 1000)})
return
if msg_type == 'pong':
return
if msg_type == 'ack':
return
if msg_type == 'error':
await self.logger.warning(f'ESPL adapter gateway error: {frame.get("code")} {frame.get("message")}')
return
# Inbound message envelope (message.received).
if frame.get('event_type') == 'message.received':
await self._handle_inbound_message(conn, frame)
return
await self.logger.debug(f'ESPL adapter unhandled frame type: {msg_type}')
def _start_inbound_task(self, coro) -> asyncio.Task | None:
self.inbound_tasks = {task for task in self.inbound_tasks if not task.done()}
task = asyncio.create_task(coro)
self.inbound_tasks.add(task)
def task_done(done_task: asyncio.Task) -> None:
self.inbound_tasks.discard(done_task)
if not done_task.cancelled():
done_task.exception()
task.add_done_callback(task_done)
return task
async def _handle_inbound_message(self, conn: _EsplConnection, envelope: dict) -> None:
"""Convert a message.received envelope into a LangBot event and fire it."""
payload = envelope.get('payload') or {}
if not isinstance(payload, dict):
await self.logger.warning('ESPL adapter inbound payload is not an object')
return
conversation_id = str(payload.get('conversation_id') or '')
sender_id = str(payload.get('sender_id') or '')
sender_name = str(payload.get('sender_name') or 'User')
message_content = str(payload.get('message_content') or '')
instance_id = str(payload.get('instance') or payload.get('instance_id') or '')
platform = str(payload.get('platform_id') or envelope.get('platform') or '')
if not conversation_id:
await self.logger.warning('ESPL adapter inbound message missing conversation_id')
return
chain = self._build_message_chain(payload.get('message_chain'), message_content)
# Stash routing context (instance_id, conversation_id, conn_key) on the
# event so outbound replies route back to the correct connection.
source_platform_object = {
'instance_id': instance_id,
'conversation_id': conversation_id,
'platform': platform,
'sender_id': sender_id,
'_conn': conn,
}
session_type = str(payload.get('session_type') or 'person')
if session_type == 'group':
group = platform_entities.Group(
id=conversation_id,
name=str(payload.get('group_name') or conversation_id),
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=sender_id or conversation_id,
member_name=sender_name,
group=group,
permission=platform_entities.Permission.Member,
)
event = platform_events.GroupMessage(
sender=sender,
message_chain=chain,
time=datetime.now().timestamp(),
source_platform_object=source_platform_object,
)
else:
sender = platform_entities.Friend(
id=conversation_id,
nickname=sender_name,
remark=sender_name,
)
event = platform_events.FriendMessage(
sender=sender,
message_chain=chain,
time=datetime.now().timestamp(),
source_platform_object=source_platform_object,
)
listener = self.listeners.get(type(event))
if listener is None:
await self.logger.warning(f'ESPL adapter no listener for {type(event).__name__}')
return
await self.logger.info(
f'ESPL adapter inbound: conversation={conversation_id} sender={sender_name} '
f'content={message_content[:100]}'
)
self._start_inbound_task(listener(event, self))
def _build_message_chain(
self,
message_chain: typing.Any,
fallback_text: str,
) -> platform_message.MessageChain:
"""Convert an ESPL message_chain into a LangBot MessageChain."""
components: list[platform_message.MessageComponent] = []
if isinstance(message_chain, list):
for elem in message_chain:
if not isinstance(elem, dict):
continue
elem_type = elem.get('type', '')
content = elem.get('content')
if elem_type == 'text':
text = ''
if isinstance(content, dict):
text = str(content.get('text', ''))
elif isinstance(content, str):
text = content
else:
text = str(elem.get('text', ''))
if text:
components.append(platform_message.Plain(text=text))
elif elem_type == 'image':
url = ''
if isinstance(content, dict):
url = str(content.get('url', ''))
elif isinstance(content, str):
url = content
else:
url = str(elem.get('url', ''))
if url:
components.append(platform_message.Image(url=url))
elif elem_type in ('item', 'product', 'goods'):
# E-commerce product card (e.g. 闲鱼 itemInfo).
# Render as a plain-text description so the product info
# (title/price) is not dropped downstream.
title = ''
price = ''
if isinstance(content, dict):
title = str(content.get('title') or '')
price = str(content.get('price') or '')
elif isinstance(content, str):
title = content
else:
title = str(elem.get('title') or '')
price = str(elem.get('price') or '')
product_text = title
if price:
product_text = f'{title} [价格: {price}]' if title else f'价格: {price}'
if product_text:
components.append(platform_message.Plain(text=product_text))
if not components and fallback_text:
components.append(platform_message.Plain(text=fallback_text))
return platform_message.MessageChain(components)
# -- outbound -------------------------------------------------------------
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain) -> dict:
"""Proactively push a message to a conversation (target_id == conversation_id)."""
return await self._emit_outbound(target_id, message)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict:
return await self._emit_outbound_from_event(message_source, message)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict:
# ESPL v3 has no streaming; send the whole chunk as a final message.
return await self._emit_outbound_from_event(message_source, message)
async def _emit_outbound_from_event(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
) -> dict:
"""Send a reply, routing back to the connection captured at inbound."""
source = getattr(message_source, 'source_platform_object', None) or {}
conn = source.get('_conn')
conversation_id = str(source.get('conversation_id') or '')
instance_id = str(source.get('instance_id') or '')
sender_id = str(source.get('sender_id') or '')
if not conversation_id:
conversation_id = str(self.get_launcher_id(message_source))
return await self._emit_outbound(
conversation_id,
message,
instance_id=instance_id,
sender_id=sender_id,
conn=conn,
)
async def _emit_outbound(
self,
conversation_id: str,
message: platform_message.MessageChain,
instance_id: str = '',
sender_id: str = '',
conn: _EsplConnection | None = None,
) -> dict:
"""Build and send an ESPL v3 outbound message frame."""
if conn is None:
# Try to find a connection for this conversation by scanning.
if not self.connections:
await self.logger.warning('ESPL adapter no connections; dropping outbound message')
return {}
conn = next(iter(self.connections.values()))
# Convert the LangBot message chain to ESPL chain elements.
chain = []
for component in message:
if isinstance(component, platform_message.Plain):
chain.append({'type': 'text', 'content': {'text': component.text}})
elif isinstance(component, platform_message.Image):
chain.append({'type': 'image', 'content': {'url': component.url or ''}})
frame = {
'type': 'message',
'id': 'out_' + uuid.uuid4().hex,
'timestamp': int(time.time() * 1000),
'payload': {
'instance_id': instance_id,
'command_type': 'send_text',
'conversation_id': conversation_id,
'target_id': sender_id or conversation_id,
'sender_id': sender_id,
'message_chain': chain,
},
}
try:
await conn.send_frame(frame)
except Exception as e:
await self.logger.error(f'ESPL adapter failed to send outbound: {e}')
return {}
await self.logger.info(f'ESPL adapter outbound: conversation={conversation_id} chain={chain}')
return frame
# -- heartbeat ------------------------------------------------------------
async def _heartbeat_loop(self) -> None:
"""Periodically ping all connected clients to keep connections alive."""
interval = int(self.config.get('heartbeat_interval', _DEFAULT_HEARTBEAT_INTERVAL))
while self.running:
await asyncio.sleep(interval)
for conn in list(self.connections.values()):
try:
await conn.send_frame({'type': 'ping', 'timestamp': int(time.time() * 1000)})
except Exception as e:
await self.logger.warning(f'ESPL adapter heartbeat to client failed: {e}')
@@ -1,83 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: espl
label:
en_US: ESPL V3
zh_Hans: ESPL V3
zh_Hant: ESPL V3
ja_JP: ESPL V3
description:
en_US: "LangBot acts as a WebSocket server. E-SP-Line2 creates a client-mode adapter (接入器) pointing its ws_url to this endpoint. Receives e-commerce messages (Taobao / Xianyu) as inbound events and sends AI replies back to the platform."
zh_Hans: "LangBot 作为 WebSocket 服务端。在 E-SP-Line2 中创建客户端模式接入器,将 ws_url 指向本端点即可接入。接收电商平台(淘宝/闲鱼)消息作为入站事件,并将 AI 回复发回平台。"
zh_Hant: "LangBot 作為 WebSocket 服務端。在 E-SP-Line2 中建立用戶端模式接入器,將 ws_url 指向本端點即可接入。接收電商平台(淘寶/閒魚)訊息作為入站事件,並將 AI 回覆發回平台。"
ja_JP: "LangBot が WebSocket サーバーとして動作します。E-SP-Line2 でクライアントモードのアダプター(接入器)を作成し、ws_url をこのエンドポイントに向けます。EC プラットフォーム(Taobao / Xianyu)のメッセージをインバウンドイベントとして受信し、AI 返信をプラットフォームに送り返します。"
icon: espl.png
spec:
categories:
- global
help_links:
zh: https://docs.langbot.app/zh/platforms/espl
en: https://docs.langbot.app/en/platforms/espl
ja: https://docs.langbot.app/ja/platforms/espl
config:
- name: host
label:
en_US: Listen Host
zh_Hans: 监听主机
zh_Hant: 監聽主機
ja_JP: リッスンホスト
description:
en_US: "Host to bind the WebSocket server. Set 0.0.0.0 when E-SP-Line2 is on another machine."
zh_Hans: "WebSocket 服务端绑定的主机。E-SP-Line2 在其他机器时设为 0.0.0.0。"
zh_Hant: "WebSocket 服務端綁定的主機。E-SP-Line2 在其他機器時設為 0.0.0.0。"
ja_JP: "WebSocket サーバーをバインドするホスト。E-SP-Line2 が別マシンの場合は 0.0.0.0 を設定します。"
type: string
required: true
default: "127.0.0.1"
- name: port
label:
en_US: Listen Port
zh_Hans: 监听端口
zh_Hant: 監聽連接埠
ja_JP: リッスンポート
description:
en_US: "Port to bind the WebSocket server. E-SP-Line2 client-mode adapter connects to ws://<host>:<port>/ws."
zh_Hans: "WebSocket 服务端绑定的端口。E-SP-Line2 客户端模式接入器连接 ws://<host>:<port>/ws。"
zh_Hant: "WebSocket 服務端綁定的連接埠。E-SP-Line2 用戶端模式接入器連接 ws://<host>:<port>/ws。"
ja_JP: "WebSocket サーバーをバインドするポート。E-SP-Line2 クライアントモードアダプターは ws://<host>:<port>/ws に接続します。"
type: integer
required: false
default: 8000
- name: key
label:
en_US: Access Key
zh_Hans: 访问密钥
zh_Hant: 訪問密鑰
ja_JP: アクセスキー
description:
en_US: "Access key for authentication. E-SP-Line2 client-mode adapter passes this key as ?key=<KEY> in the WebSocket URL. Leave empty to disable key validation (not recommended)."
zh_Hans: "访问密钥用于认证。E-SP-Line2 客户端模式接入器在 WebSocket URL 中携带 ?key=<KEY> 传递此密钥。留空表示不验证密钥(不推荐)。"
zh_Hant: "訪問密鑰用於認證。E-SP-Line2 用戶端模式接入器在 WebSocket URL 中攜帶 ?key=<KEY> 傳遞此密鑰。留空表示不驗證密鑰(不推薦)。"
ja_JP: "認証用のアクセスキー。E-SP-Line2 クライアントモードアダプターは WebSocket URL に ?key=<KEY> としてこのキーを渡します。空の場合はキー検証を無効にします(非推奨)。"
type: string
required: false
default: ""
- name: heartbeat_interval
label:
en_US: Heartbeat Interval (seconds)
zh_Hans: 心跳间隔(秒)
zh_Hant: 心跳間隔(秒)
ja_JP: ハートビート間隔(秒)
description:
en_US: "How often to ping connected clients to keep the connection alive."
zh_Hans: "发送 ping 帧保持连接的间隔。"
zh_Hant: "發送 ping 幀保持連線的間隔。"
ja_JP: "接続を維持するためにクライアントに ping を送信する間隔。"
type: integer
required: false
default: 30
execution:
python:
path: ./espl.py
attr: EsplAdapter
@@ -18,9 +18,9 @@ spec:
- popular
- global
help_links:
zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot
ja: https://langbot.app/docs/ja/platforms/http-bot
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
ja: https://docs.langbot.app/ja/platforms/http-bot
config:
- name: webhook_url
label:
+3 -3
View File
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/kook
en: https://langbot.app/docs/en/usage/platforms/kook
ja: https://langbot.app/docs/ja/usage/platforms/kook
zh: https://link.langbot.app/zh/platforms/kook
en: https://link.langbot.app/en/platforms/kook
ja: https://link.langbot.app/ja/platforms/kook
config:
- name: token
label:
+4 -32
View File
@@ -160,29 +160,6 @@ def _lark_should_update_stream_element(
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
def _lark_final_layout_texts(
*,
resume_from: bool,
text_message: str,
pre_pause_cached: str | None,
resume_cached: str,
) -> tuple[str, str]:
"""Return (main_text, resume_placeholder_text) for the final card update.
Non-resume round: the full reply belongs in the main streaming element
only also rendering the resume placeholder duplicates the reply, since
both hold the same accumulated text. Resume round (Dify HITL): keep the
pre-pause text in the main element and the resumed text in the
placeholder, as they are distinct segments.
"""
if resume_from:
# An empty pre-pause cache is valid (Dify paused before emitting any
# text); only a missing entry (None) falls back to the full text.
main_text = text_message if pre_pause_cached is None else pre_pause_cached
return main_text, resume_cached
return text_message, ''
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
field_type = _dify_field_type(field)
if field_type == 'file':
@@ -2381,21 +2358,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
else:
# Normal finish: remove buttons/notice and finalize the card.
main_text, resume_text = _lark_final_layout_texts(
resume_from=resume_from,
text_message=text_message,
pre_pause_cached=self.card_pre_pause_text.get(card_id),
resume_cached=resume_cached,
)
# Normal finish: keep pre-pause + resume content visible,
# remove buttons/notice, drop the resume placeholder.
await self._update_card_layout(
card_id=card_id,
message_source=message_source,
text_message=main_text,
text_message=pre_pause,
sequence=final_seq,
form_data=None,
notice_text=selected_notice if resume_from else '',
resume_placeholder_text=resume_text,
resume_placeholder_text=resume_cached,
)
self._drop_card_state(card_id)
self.card_id_dict.pop(message_id, None)
+3 -3
View File
@@ -19,9 +19,9 @@ spec:
- china
- global
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/lark
en: https://langbot.app/docs/en/usage/platforms/lark
ja: https://langbot.app/docs/ja/usage/platforms/lark
zh: https://link.langbot.app/zh/platforms/lark
en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark
config:
- name: domain
label:
+8 -51
View File
@@ -25,7 +25,6 @@ from linebot.v3.webhooks import (
ImageMessageContent,
VideoMessageContent,
AudioMessageContent,
UserMentionee,
)
# from linebot import WebhookParser
@@ -59,19 +58,15 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return content_list
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
@staticmethod
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
lb_msg_list = []
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
if isinstance(message.message, TextMessageContent):
lb_msg_list.extend(
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
)
lb_msg_list.append(platform_message.Plain(text=message.message.text))
elif isinstance(message.message, AudioMessageContent):
pass
elif isinstance(message.message, VideoMessageContent):
@@ -91,55 +86,17 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Image(base64=data_uri))
return platform_message.MessageChain(lb_msg_list)
def _build_text_components(self, text: str, mention) -> list:
"""Build message components from text, inserting At components for mentions.
LINE provides mention positions (index/length) and is_self per mentionee in the
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
'at-bot' group respond rule work for LINE, consistent with other adapters.
"""
components: list = []
if not mention or not mention.mentionees:
if text:
components.append(platform_message.Plain(text=text))
return components
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
cursor = 0
for start, end, mentionee in segments:
if start < cursor:
start, end = cursor, min(end, len(text))
if start < cursor or end <= start or end > len(text):
continue
if start > cursor:
components.append(platform_message.Plain(text=text[cursor:start]))
if isinstance(mentionee, UserMentionee):
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
if not target:
target = text[start:end]
else:
target = text[start:end]
# At.__str__ already prepends '@', so strip one from the LINE text token.
display = text[start:end].lstrip('@')
components.append(platform_message.At(target=str(target), display=display))
cursor = end
if cursor < len(text):
components.append(platform_message.Plain(text=text[cursor:]))
return components
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
self.message_converter = LINEMessageConverter(bot_account_id)
@staticmethod
async def yiri2target(
event: platform_events.MessageEvent,
) -> MessageEvent:
pass
async def target2yiri(self, event, bot_client) -> platform_events.Event:
message_chain = await self.message_converter.target2yiri(event, bot_client)
@staticmethod
async def target2yiri(event, bot_client) -> platform_events.Event:
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
if event.source.type == 'user':
return platform_events.FriendMessage(
@@ -212,8 +169,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners={},
card_id_dict={},
seq=1,
event_converter=LINEEventConverter(bot_account_id),
message_converter=LINEMessageConverter(bot_account_id),
event_converter=LINEEventConverter(),
message_converter=LINEMessageConverter(),
line_webhook=line_webhook,
parser=parser,
configuration=configuration,
+3 -3
View File
@@ -22,9 +22,9 @@ spec:
categories:
- global
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/line
en: https://langbot.app/docs/en/usage/platforms/line
ja: https://langbot.app/docs/ja/usage/platforms/line
zh: https://link.langbot.app/zh/platforms/line
en: https://link.langbot.app/en/platforms/line
ja: https://link.langbot.app/ja/platforms/line
config:
- name: webhook_url
label:
+2 -2
View File
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
continue
# Use configured logout command, fallback to deriving from login command
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
# Use configured logout command, fallback to deriving from login command
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
# Cancel existing tasks
@@ -1,375 +0,0 @@
from __future__ import annotations
import asyncio
import json
import re
import typing
from urllib.parse import urlsplit, urlunsplit
import aiohttp
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
_MATTERMOST_MAX_POST_LENGTH = 16_383
_MENTION_BOUNDARY = r'(?<![\w.-])@{username}(?![\w.-])'
def _normalize_server_url(server_url: str) -> str:
"""Return a validated Mattermost server URL without a trailing slash."""
url = server_url.strip().rstrip('/')
parsed = urlsplit(url)
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL')
return url
def _websocket_url(server_url: str) -> str:
parsed = urlsplit(server_url)
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', ''))
class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Translate Mattermost post text to and from LangBot message chains."""
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
parts: list[str] = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
parts.append(component.text)
elif isinstance(component, platform_message.Image) and component.url:
# Mattermost renders image URLs in Markdown messages.
parts.append(component.url)
elif isinstance(component, platform_message.File) and component.url:
parts.append(component.url)
return ''.join(parts)
@staticmethod
async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain:
text = str(post.get('message') or '')
components: list[typing.Any] = [
platform_message.Source(
id=str(post.get('id') or ''),
time=float(post.get('create_at') or 0) / 1000,
)
]
if bot_username:
mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE)
if mention_pattern.search(text):
components.append(platform_message.At(target=bot_username))
text = mention_pattern.sub('', text).strip()
if text:
components.append(platform_message.Plain(text=text))
return platform_message.MessageChain(components)
class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
return event.source_platform_object
@staticmethod
async def target2yiri(
post: dict,
channel: dict,
sender_name: str,
bot_username: str,
) -> platform_events.MessageEvent:
message_chain = await MattermostMessageConverter.target2yiri(post, bot_username)
timestamp = float(post.get('create_at') or 0) / 1000
sender_id = str(post.get('user_id') or '')
channel_type = channel.get('type')
if channel_type == 'D':
return platform_events.FriendMessage(
sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''),
message_chain=message_chain,
time=timestamp,
source_platform_object={'post': post, 'channel': channel},
)
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_id,
member_name=sender_name or sender_id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=str(post.get('channel_id') or ''),
name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''),
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=timestamp,
source_platform_object={'post': post, 'channel': channel},
)
class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Mattermost Bot Account adapter using the v4 REST and WebSocket APIs."""
server_url: str = ''
access_token: str = ''
session: aiohttp.ClientSession | None = None
listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {}
channel_cache: dict[str, dict] = {}
stream_post_ids: dict[str, str] = {}
bot_username: str = ''
_running: bool = False
message_converter: MattermostMessageConverter = MattermostMessageConverter()
event_converter: MattermostEventConverter = MattermostEventConverter()
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
server_url = _normalize_server_url(str(config.get('server_url') or ''))
access_token = str(config.get('access_token') or '').strip()
if not access_token:
raise ValueError('Mattermost adapter requires an access_token')
super().__init__(
config=config,
logger=logger,
server_url=server_url,
access_token=access_token,
bot_account_id='',
session=None,
listeners={},
channel_cache={},
stream_post_ids={},
bot_username='',
_running=False,
)
async def _get_session(self) -> aiohttp.ClientSession:
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession(
headers={'Authorization': f'Bearer {self.access_token}'},
raise_for_status=False,
)
return self.session
async def _api_request(
self,
method: str,
path: str,
*,
payload: dict | None = None,
) -> dict:
session = await self._get_session()
async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response:
raw_body = await response.text()
if response.status >= 400:
# Mattermost returns a useful JSON error, but never include request headers/tokens in errors.
try:
error = json.loads(raw_body).get('message', raw_body)
except json.JSONDecodeError:
error = raw_body
raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}')
if not raw_body:
return {}
return json.loads(raw_body)
async def _load_bot_identity(self) -> None:
user = await self._api_request('GET', '/users/me')
self.bot_account_id = str(user.get('id') or '')
self.bot_username = str(user.get('username') or '')
if not self.bot_account_id:
raise RuntimeError('Mattermost API did not return a bot user ID')
async def _get_channel(self, channel_id: str) -> dict:
if channel_id not in self.channel_cache:
self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}')
return self.channel_cache[channel_id]
async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict:
if not text:
return {}
if len(text) > _MATTERMOST_MAX_POST_LENGTH:
raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters')
payload = {'channel_id': channel_id, 'message': text}
if root_id:
payload['root_id'] = root_id
return await self._api_request('POST', '/posts', payload=payload)
async def _get_direct_channel_id(self, user_id: str) -> str:
if not self.bot_account_id:
await self._load_bot_identity()
channel = await self._api_request(
'POST',
'/channels/direct',
payload={'user_ids': [self.bot_account_id, user_id]},
)
channel_id = str(channel.get('id') or '')
if not channel_id:
raise RuntimeError('Mattermost did not return a direct-message channel ID')
self.channel_cache[channel_id] = channel
return channel_id
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
if target_type not in {'person', 'group'}:
raise ValueError("Mattermost target_type must be 'person' or 'group'")
text = await self.message_converter.yiri2target(message)
channel_id = str(target_id)
if target_type == 'person':
channel_id = await self._get_direct_channel_id(channel_id)
await self._post_message(channel_id, text)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
source = await self.event_converter.yiri2target(message_source)
post = source['post']
text = await self.message_converter.yiri2target(message)
# A message received inside a Mattermost thread must remain in that thread. When
# quote_origin is requested, make the response a reply to the source root post.
root_id = str(post.get('root_id') or '')
if quote_origin and not root_id:
root_id = str(post.get('id') or '')
await self._post_message(str(post['channel_id']), text, root_id)
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
source = await self.event_converter.yiri2target(event)
post = source['post']
root_id = str(post.get('root_id') or post.get('id') or '')
created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id)
if created.get('id'):
self.stream_post_ids[str(message_id)] = str(created['id'])
return True
return False
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
response_id = str(bot_message.resp_message_id)
text = await self.message_converter.yiri2target(message)
if not text:
return
post_id = self.stream_post_ids.get(response_id)
if post_id:
await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text})
else:
source = await self.event_converter.yiri2target(message_source)
post = source['post']
root_id = str(post.get('root_id') or '')
if quote_origin and not root_id:
root_id = str(post.get('id') or '')
created = await self._post_message(str(post['channel_id']), text, root_id)
post_id = str(created.get('id') or '')
if post_id:
self.stream_post_ids[response_id] = post_id
if is_final and getattr(bot_message, 'tool_calls', None) is None:
self.stream_post_ids.pop(response_id, None)
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable_stream_reply', True))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners.pop(event_type, None)
async def _dispatch_post(self, payload: dict) -> None:
data = payload.get('data') or {}
try:
post = json.loads(data.get('post') or '{}')
except (TypeError, json.JSONDecodeError):
await self.logger.error('Mattermost received a posted event with an invalid post payload')
return
if not post or str(post.get('user_id') or '') == self.bot_account_id:
return
channel_id = str(post.get('channel_id') or '')
if not channel_id:
return
try:
channel = await self._get_channel(channel_id)
event = await self.event_converter.target2yiri(
post,
channel,
str(data.get('sender_name') or post.get('user_id') or ''),
self.bot_username,
)
callback = self.listeners.get(type(event))
if callback:
result = callback(event, self)
if asyncio.iscoroutine(result):
await result
except Exception as exc:
await self.logger.error(f'Error handling Mattermost post: {exc}')
async def _run_websocket_once(self) -> None:
session = await self._get_session()
async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket:
await websocket.send_json(
{
'seq': 1,
'action': 'authentication_challenge',
'data': {'token': self.access_token},
}
)
async for message in websocket:
if message.type == aiohttp.WSMsgType.TEXT:
try:
payload = json.loads(message.data)
except json.JSONDecodeError:
continue
if payload.get('event') == 'posted':
await self._dispatch_post(payload)
elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}:
break
async def run_async(self):
self._running = True
await self._load_bot_identity()
await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})')
retry_delay = 1
while self._running:
try:
await self._run_websocket_once()
retry_delay = 1
except asyncio.CancelledError:
raise
except Exception as exc:
if self._running:
await self.logger.error(f'Mattermost WebSocket disconnected: {exc}')
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30)
async def kill(self) -> bool:
self._running = False
if self.session and not self.session.closed:
await self.session.close()
return True
@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Artwork" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140"><defs><style>.cls-1{fill:#1e325c;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M111.11,13.36l.74,14.86c12.04,13.3,16.8,32.15,10.81,49.86-8.95,26.44-38.46,40.33-65.92,31.04-27.46-9.29-42.45-38.26-33.5-64.7,6.01-17.77,21.32-29.87,39.05-33.07L71.87.03C41.99-.77,13.8,17.77,3.72,47.55c-12.4,36.6,7.24,76.33,43.85,88.73,36.6,12.4,76.33-7.24,88.73-43.85,10.07-29.74-1-61.55-25.14-79.07h-.03Z"/><path class="cls-1" d="M93.95,57.21l-.51-20.77-.41-11.95-.28-10.35s.07-4.99-.11-6.16c-.03-.25-.11-.44-.21-.62,0-.03-.02-.05-.03-.07,0-.02-.03-.05-.03-.07-.2-.33-.49-.59-.89-.72s-.8-.1-1.17.05h-.02s-.08.03-.13.07c-.16.08-.34.2-.51.36-.85.82-3.84,4.83-3.84,4.83l-6.5,8.06-7.59,9.25-13.02,16.19s-5.98,7.46-4.65,16.64c1.31,9.18,8.15,13.65,13.43,15.44,5.29,1.79,13.43,2.38,20.05-4.11,6.62-6.49,6.4-16.04,6.4-16.04l.02-.02Z"/></svg>

Before

Width:  |  Height:  |  Size: 938 B

@@ -1,75 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: mattermost
label:
en_US: Mattermost
zh_Hans: Mattermost
zh_Hant: Mattermost
ja_JP: Mattermost
th_TH: Mattermost
vi_VN: Mattermost
es_ES: Mattermost
icon: mattermost.svg
description:
en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this.
zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。
zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。
ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。
th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป
vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn.
es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio.
spec:
categories:
- global
- popular
config:
- name: server_url
label:
en_US: Mattermost Server URL
zh_Hans: Mattermost 服务器地址
zh_Hant: 位址伺服器 Mattermost
ja_JP: Mattermost サーバー URL
th_TH: URL เซิร์ฟเวอร์ Mattermost
vi_VN: URL máy chủ Mattermost
es_ES: URL del servidor Mattermost
description:
en_US: The base URL of the Mattermost server, for example https://mattermost.example.com
zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com
type: string
required: true
default: ""
- name: access_token
label:
en_US: Bot Access Token
zh_Hans: Bot 访问令牌
zh_Hant: Bot 存取權杖
ja_JP: Bot アクセストークン
th_TH: โทเค็นการเข้าถึงของบอต
vi_VN: Mã truy cập Bot
es_ES: Token de acceso del bot
description:
en_US: The personal access token generated for the Mattermost Bot Account
zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌
type: string
required: true
default: ""
- name: enable_stream_reply
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
ja_JP: ストリーミング返信を有効化
th_TH: เปิดใช้งานการตอบกลับแบบสตรีม
vi_VN: Bật phản hồi luồng
es_ES: Activar respuesta en streaming
description:
en_US: Update a Mattermost post while LangBot generates a response
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息
type: boolean
required: false
default: true
execution:
python:
path: ./mattermost.py
attr: MattermostAdapter
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
en: https://langbot.app/docs/en/usage/platforms/wxoa
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
zh: https://link.langbot.app/zh/platforms/officialaccount
en: https://link.langbot.app/en/platforms/officialaccount
ja: https://link.langbot.app/ja/platforms/officialaccount
config:
- name: webhook_url
label:
@@ -16,9 +16,9 @@ spec:
- popular
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
en: https://link.langbot.app/en/platforms/openclaw_weixin
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
config:
- name: base_url
label:
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot = QQOfficialClient(
app_id=config['appid'],
secret=config['secret'],
token=config.get('token', ''),
token=config['token'],
logger=logger,
unified_mode=enable_webhook,
)
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
zh: https://link.langbot.app/zh/platforms/qqofficial
en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial
config:
- name: __system.outbound_ips
label:
+3 -3
View File
@@ -21,9 +21,9 @@ spec:
categories:
- protocol
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/readme
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
zh: https://link.langbot.app/zh/platforms/satori
en: https://link.langbot.app/en/platforms/satori
ja: https://link.langbot.app/ja/platforms/satori
config:
- name: platform
label:
+3 -3
View File
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/slack
en: https://langbot.app/docs/en/usage/platforms/slack
ja: https://langbot.app/docs/ja/usage/platforms/slack
zh: https://link.langbot.app/zh/platforms/slack
en: https://link.langbot.app/en/platforms/slack
ja: https://link.langbot.app/ja/platforms/slack
config:
- name: webhook_url
label:
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/telegram
en: https://langbot.app/docs/en/usage/platforms/telegram
ja: https://langbot.app/docs/ja/usage/platforms/telegram
zh: https://link.langbot.app/zh/platforms/telegram
en: https://link.langbot.app/en/platforms/telegram
ja: https://link.langbot.app/ja/platforms/telegram
config:
- name: token
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
zh: https://link.langbot.app/zh/platforms/wechatpad
en: https://link.langbot.app/en/platforms/wechatpad
ja: https://link.langbot.app/ja/platforms/wechatpad
config:
- name: wechatpad_url
label:
+3 -3
View File
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if content['type'] == 'text':
await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media_id'])
await self.bot.send_image(user_id, agent_id, content['media'])
if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media_id'])
await self.bot.send_voice(user_id, agent_id, content['media'])
if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media_id'])
await self.bot.send_file(user_id, agent_id, content['media'])
def register_listener(
self,
+3 -3
View File
@@ -16,9 +16,9 @@ spec:
- popular
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
zh: https://link.langbot.app/zh/platforms/wecom
en: https://link.langbot.app/en/platforms/wecom
ja: https://link.langbot.app/ja/platforms/wecom
config:
- name: webhook_url
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
zh: https://link.langbot.app/zh/platforms/wecombot
en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot
config:
- name: one-click-create
label:
+3 -10
View File
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend(
id=f'{event.receiver_id}|u{event.user_id}',
id=f'u{event.user_id}',
nickname=nickname,
remark='',
)
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
elif event.type == 'image':
friend = platform_entities.Friend(
id=f'{event.receiver_id}|u{event.user_id}',
id=f'u{event.user_id}',
nickname=nickname,
remark='',
)
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list:
msgid = f'{uuid.uuid4().hex}'
msgid = f'langbot_{uuid.uuid4().hex}'
if content['type'] == 'text':
await self.bot.send_text_msg(
open_kfid=open_kfid,
@@ -205,13 +205,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
msgid=msgid,
content=content['content'],
)
elif content['type'] == 'image':
await self.bot.send_image_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
media_id=content['media_id'],
)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL"""
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
zh: https://link.langbot.app/zh/platforms/wecomcs
en: https://link.langbot.app/en/platforms/wecomcs
ja: https://link.langbot.app/ja/platforms/wecomcs
config:
- name: webhook_url
label:
+2 -7
View File
@@ -1913,14 +1913,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
runtime_handler = self._runtime_handler()
try:
binding = await self._target_binding(author, plugin_name)
except ValueError as exc:
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
return None
raise
binding = await self._target_binding(author, plugin_name)
with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_info(author, plugin_name)
@@ -1,420 +0,0 @@
"""ChatGPT device auth with server-only credentials and cross-process refresh leases.
Network I/O never holds a DB transaction. A persisted CAS lease serializes refresh
and poll; cancel fences device exchanges but waits for existing-token refreshes.
"""
from __future__ import annotations
import asyncio
import base64
import json
import math
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import httpx
import sqlalchemy as sa
from ...entity.persistence.model import CodexCredential, ModelProvider
from ...api.http.context import PrincipalType, RequestContext
from ...api.http.authz import Permission, has_permission
from ...api.http.service.tenant import require_workspace_uuid
from ...workspace.errors import WorkspaceNotFoundError
REQUESTER = 'openai-codex'
BASE_URL = 'https://chatgpt.com/backend-api/codex'
ISSUER = 'https://auth.openai.com'
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
LOGIN_REQUIRED = 'ChatGPT sign-in required. Open this provider and sign in again.'
LEASE_SECONDS = 90
def validate_config(data: dict) -> None:
if data.get('requester') != REQUESTER:
return
if data.get('base_url') not in (None, '', BASE_URL):
raise ValueError('Codex uses the fixed ChatGPT endpoint; custom base URLs are not supported')
if data.get('api_keys') not in (None, [], ''):
raise ValueError('Codex uses ChatGPT sign-in, not API keys')
data['base_url'] = BASE_URL
data['api_keys'] = []
def _claims(token: str) -> dict:
"""Read routing metadata, NOT trusted LangBot identity, from issuer tokens."""
try:
part = token.split('.')[1]
value = json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))
return value if isinstance(value, dict) else {}
except (ValueError, IndexError, TypeError):
return {}
def _tokens(data: dict, previous: dict | None = None) -> dict:
previous = previous or {}
access = data.get('access_token')
refresh = data.get('refresh_token') or previous.get('refresh_token')
account = None
for token in (access, data.get('id_token')):
namespace = _claims(token or '').get('https://api.openai.com/auth', {})
if isinstance(namespace, dict) and isinstance(namespace.get('chatgpt_account_id'), str):
account = namespace['chatgpt_account_id']
break
account = account or previous.get('account_id')
try:
expires_at = (
time.time() + float(data['expires_in'])
if data.get('expires_in') is not None
else float(_claims(access or '').get('exp', 0))
)
except (TypeError, ValueError):
expires_at = 0
if (
not all(isinstance(v, str) and v for v in (access, refresh, account))
or not math.isfinite(expires_at)
or expires_at <= time.time()
):
raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.')
return {
'access_token': access,
'refresh_token': refresh,
'account_id': account,
'expires_at': expires_at,
'connection_id': previous.get('connection_id') or secrets.token_urlsafe(24),
}
class CodexAuth:
def __init__(self, ap):
self.ap = ap
def _where(self, workspace: str, provider: str):
return (CodexCredential.workspace_uuid == workspace, CodexCredential.provider_uuid == provider)
async def _execute(self, statement):
# SQLAlchemy/driver/serialization errors may embed the entire secret payload.
try:
return await self.ap.persistence_mgr.execute_async(statement)
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _read(self, workspace: str, provider: str) -> dict | None:
result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider)))
try:
row = result.first()
return dict(row._mapping) if row is not None else None
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _provider(self, context, provider: str, *, user: bool = False) -> str:
workspace = require_workspace_uuid(context)
if user and (
not isinstance(context, RequestContext)
or context.principal.principal_type != PrincipalType.ACCOUNT
or not context.account_uuid
or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE)
):
raise ValueError('ChatGPT authorization requires an authorized workspace user')
result = await self._execute(
sa.select(ModelProvider.requester).where(
ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider
)
)
kind = result.scalar()
if kind is None:
raise WorkspaceNotFoundError('Provider not found')
if kind != REQUESTER:
raise ValueError('This provider does not use ChatGPT sign-in')
return workspace
@asynccontextmanager
async def _lease(self, workspace: str, provider: str, *, refresh: bool = False):
owner = ('refresh:' if refresh else 'device:') + secrets.token_urlsafe(32)
deadline = time.monotonic() + 65
while True:
now = time.time()
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
sa.or_(CodexCredential.lease_owner.is_(None), CodexCredential.lease_until < now),
)
.values(lease_owner=owner, lease_until=now + LEASE_SECONDS)
)
if result.rowcount == 1:
break
if await self._read(workspace, provider) is None:
raise ValueError(LOGIN_REQUIRED)
if time.monotonic() >= deadline:
raise ValueError('ChatGPT authorization is busy. Please retry shortly.')
await asyncio.sleep(0.1)
try:
yield owner
finally:
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider), CodexCredential.lease_owner == owner)
.values(lease_owner=None, lease_until=0)
)
async def _save(self, workspace: str, provider: str, owner: str, payload: dict) -> None:
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.lease_owner == owner,
CodexCredential.lease_until > time.time(),
)
.values(payload=payload, version=CodexCredential.version + 1)
)
if result.rowcount != 1:
raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.')
async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response:
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
return await asyncio.wait_for(
client.post(
ISSUER + path,
data=data,
json=json_body,
headers={'Accept': 'application/json', 'User-Agent': 'LangBot'},
),
25,
)
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT authorization network error. Please retry.') from None
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
value = response.json()
if not isinstance(value, dict):
raise ValueError
return value
except ValueError:
raise ValueError('ChatGPT returned an invalid authorization response') from None
async def status(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
connected = bool(tokens and not payload.get('invalid'))
return {
'status': 'connected' if connected else 'expired' if payload.get('invalid') else 'disconnected',
'connected': connected,
'expires_at': tokens.get('expires_at') if tokens else None,
}
async def start(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
async with self._lease(workspace, provider) as owner:
response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID})
if response.status_code != 200:
raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.')
data = self._json(response)
try:
code = data.get('user_code') or data['usercode']
device = data['device_auth_id']
interval = max(5, min(60, int(data.get('interval') or 5)))
if not isinstance(code, str) or not isinstance(device, str) or not code or not device:
raise ValueError
except (KeyError, ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code') from None
now = time.time()
try:
expiry = data.get('expires_at')
if expiry is None:
expiry = now + float(data.get('expires_in', 900))
try:
expires_at = float(expiry)
except ValueError:
parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
expires_at = parsed.timestamp()
if not math.isfinite(expires_at) or expires_at <= now:
raise ValueError
expires_at = min(now + 900, expires_at)
except (ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code expiry') from None
pending = {
'authorization_id': secrets.token_urlsafe(32),
'user_code': code,
'device_auth_id': device,
'account_uuid': context.account_uuid,
'interval': interval,
'expires_at': expires_at,
'next_poll_at': now + interval,
}
row = await self._read(workspace, provider)
payload = dict(row['payload'])
payload['pending'] = pending
await self._save(workspace, provider, owner, payload)
return {k: pending[k] for k in ('authorization_id', 'user_code', 'interval', 'expires_at')} | {
'verification_uri': ISSUER + '/codex/device'
}
@staticmethod
def _attempt(payload: dict, context, authorization_id: str) -> dict | None:
pending = payload.get('pending')
if not pending or pending.get('authorization_id') != authorization_id:
return None
if pending.get('account_uuid') != context.account_uuid:
raise WorkspaceNotFoundError('Authorization not found')
return pending
async def poll(self, context, provider: str, authorization_id: str) -> dict:
workspace = await self._provider(context, provider, user=True)
if not isinstance(authorization_id, str) or not authorization_id:
raise ValueError('authorization_id is required')
async with self._lease(workspace, provider) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
pending = self._attempt(payload, context, authorization_id)
if pending is None:
completed = payload.get('completed', {})
if (
completed.get('authorization_id') == authorization_id
and completed.get('account_uuid') == context.account_uuid
):
return {'status': 'connected'}
return {'status': 'expired'}
now = time.time()
if pending['expires_at'] <= now or pending.get('consumed'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
return {'status': 'expired'}
if pending['next_poll_at'] > now:
return {'status': 'pending', 'interval': pending['interval']}
pending['next_poll_at'] = now + pending['interval']
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/api/accounts/deviceauth/token',
json_body={'device_auth_id': pending['device_auth_id'], 'user_code': pending['user_code']},
)
if response.status_code in (403, 404, 429):
if response.status_code == 429:
pending['interval'] = min(60, pending['interval'] + 5)
pending['next_poll_at'] = time.time() + pending['interval']
await self._save(workspace, provider, owner, payload)
return {'status': 'pending', 'interval': pending['interval']}
if response.status_code != 200:
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT device authorization failed. Please start again.')
data = self._json(response)
if not data.get('authorization_code') or not data.get('code_verifier'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT returned an incomplete device authorization')
# Keep an attempt tombstone so cancel can preempt exchange, but never replay a code.
pending['consumed'] = True
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/oauth/token',
data={
'grant_type': 'authorization_code',
'client_id': CLIENT_ID,
'code': data['authorization_code'],
'code_verifier': data['code_verifier'],
'redirect_uri': ISSUER + '/deviceauth/callback',
},
)
if response.status_code != 200:
raise ValueError('ChatGPT token exchange failed. Please start sign-in again.')
tokens = _tokens(self._json(response))
await self._save(
workspace,
provider,
owner,
{
'tokens': tokens,
'completed': {'authorization_id': authorization_id, 'account_uuid': context.account_uuid},
},
)
return {'status': 'connected'}
async def disconnect(self, context, provider: str) -> None:
workspace = await self._provider(context, provider, user=True)
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider))
.values(payload={}, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
async def cancel(self, context, provider: str, authorization_id: str) -> None:
workspace = await self._provider(context, provider, user=True)
deadline = time.monotonic() + 65
while time.monotonic() < deadline:
row = await self._read(workspace, provider)
if row is None:
return
old = row['payload']
if self._attempt(old, context, authorization_id) is None:
return
lease_owner = row['lease_owner']
if lease_owner and lease_owner.startswith('refresh:') and row['lease_until'] > time.time():
# A rotated refresh token must be committed before removing the attempt.
await asyncio.sleep(0.1)
continue
payload = dict(old)
payload.pop('pending', None)
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.version == row['version'],
# Lease acquisition does not change version; fence that race too.
CodexCredential.lease_owner == lease_owner,
)
.values(payload=payload, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
if result.rowcount == 1:
return
raise ValueError('Authorization changed concurrently. Please retry cancellation.')
async def access(self, context, provider: str, *, rejected_token: str | None = None) -> dict:
workspace = await self._provider(context, provider)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
async with self._lease(workspace, provider, refresh=True) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
response = await self._post(
'/oauth/token',
data={'grant_type': 'refresh_token', 'client_id': CLIENT_ID, 'refresh_token': tokens['refresh_token']},
)
error = self._json(response).get('error') if response.status_code in (400, 401, 403) else None
error_code = error.get('code') if isinstance(error, dict) else error
if error_code in (
'invalid_grant',
'refresh_token_reused',
'refresh_token_expired',
'refresh_token_revoked',
):
payload['invalid'] = True
payload.pop('tokens', None)
payload.pop('completed', None)
await self._save(workspace, provider, owner, payload)
raise ValueError(LOGIN_REQUIRED)
if response.status_code != 200:
raise ValueError('ChatGPT token refresh temporarily failed. Please retry.')
refreshed = _tokens(self._json(response), tokens)
payload['tokens'] = refreshed
await self._save(workspace, provider, owner, payload)
return refreshed
@@ -1,10 +0,0 @@
"""Explicitly safe Codex failures; never construct messages from upstream bodies."""
class CodexProviderError(ValueError):
"""A known provider failure safe to expose at the HTTP boundary."""
def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'):
super().__init__(message)
self.status_code = status_code
self.error_code = error_code
+5 -31
View File
@@ -18,7 +18,7 @@ from ...discover import engine
from ...entity.errors import provider as provider_errors
from ...entity.persistence import model as persistence_model
from ...workspace.entities import WorkspaceExecutionBinding
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
from . import requester, token
@@ -638,32 +638,10 @@ class ModelManager:
) -> requester.RuntimeLLMModel:
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
inline_codex = provider_info.get('requester') == 'openai-codex'
provider_entity = persistence_model.ModelProvider(**provider_info)
if provider_uuid:
if provider_info.get('uuid') and provider_info['uuid'] != provider_uuid:
raise ValueError('Conflicting provider identities')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
persistence_model.ModelProvider.uuid == provider_uuid,
)
)
saved_provider = result.first()
if saved_provider is None:
if inline_codex or model_info.get('provider_uuid'):
raise WorkspaceNotFoundError('Provider not found')
else:
saved_provider = self._coerce_provider(saved_provider, execution_context)
if saved_provider.requester == 'openai-codex':
# OAuth identity and transport configuration are server-owned.
provider_entity = saved_provider
elif inline_codex:
raise ValueError('This provider does not use ChatGPT sign-in')
elif inline_codex:
raise WorkspaceNotFoundError('Provider not found')
runtime_provider = await self._build_provider(execution_context, provider_entity)
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
model_entity = persistence_model.LLMModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
@@ -745,10 +723,6 @@ class ModelManager:
'requester_name': provider_entity.requester,
}
if provider_entity.requester == 'openai-codex':
config['provider_uuid'] = provider_entity.uuid
config['workspace_uuid'] = context.workspace_uuid
if litellm_provider:
from .requesters import litellmchat
@@ -1,423 +0,0 @@
"""Native ChatGPT Codex Responses/SSE requester (never Chat Completions)."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from collections import OrderedDict
import httpx
import langbot
import langbot_plugin.api.entities.builtin.provider.message as pm
from .. import requester, reasoning
from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED
from ..codex_errors import CodexProviderError
async def sse_events(response):
"""Decode SSE records, including CRLF, comments, and multiline data."""
data = []
size = 0
async for line in response.aiter_lines():
if not line:
if data:
text = '\n'.join(data)
if text == '[DONE]':
return
try:
event = json.loads(text)
if not isinstance(event, dict):
raise ValueError
except ValueError:
raise ValueError('Codex returned an invalid stream event') from None
yield event
data, size = [], 0
elif line.startswith('data:'):
value = line[5:]
if value.startswith(' '):
value = value[1:]
size += len(value)
if size > 4 * 1024 * 1024:
raise ValueError('Codex stream event exceeds the size limit')
data.append(value)
# SSE requires the blank separator; unterminated records cannot prove completion.
def _content(message):
content = message.content
if isinstance(content, str):
return [{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': content}]
result = []
for part in content or []:
if part.type == 'text':
result.append(
{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': part.text or ''}
)
elif part.type == 'image_url' and part.image_url is not None:
result.append({'type': 'input_image', 'image_url': part.image_url.url})
elif part.type == 'image_base64' and part.image_base64:
value = part.image_base64
result.append(
{
'type': 'input_image',
'image_url': value if value.startswith('data:') else 'data:image/png;base64,' + value,
}
)
else:
raise ValueError('Codex supports text and images only; this message contains unsupported content')
return result
def _tool(item):
try:
return pm.ToolCall(
id=item['call_id'],
type='function',
function=pm.FunctionCall(name=item['name'], arguments=item.get('arguments') or ''),
)
except (KeyError, ValueError, TypeError):
raise ValueError('Codex returned an invalid function call') from None
def _usage(response):
usage = response.get('usage') or {}
return {
'prompt_tokens': usage.get('input_tokens', 0),
'completion_tokens': usage.get('output_tokens', 0),
'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)),
'prompt_tokens_details': usage.get('input_tokens_details', {}),
'completion_tokens_details': usage.get('output_tokens_details', {}),
}
class CodexRequester(requester.ProviderAPIRequester):
async def initialize(self):
self.auth = CodexAuth(self.ap)
self.workspace = self.requester_cfg['workspace_uuid']
self.provider = self.requester_cfg['provider_uuid']
# Opaque replay data stays server-side; handles are scoped to the same query,
# model and OAuth connection. No token or encrypted reasoning enters messages.
self._replay = OrderedDict()
async def aclose(self):
self._replay.clear()
def get_reasoning_capabilities(self, model):
return {
'supported': True,
'levels': ['provider_default', 'low', 'medium', 'high', 'xhigh'],
'source': 'provider',
}
@staticmethod
def _headers(tokens, *, stream=False):
return {
'Authorization': 'Bearer ' + tokens['access_token'],
'ChatGPT-Account-ID': tokens['account_id'],
'User-Agent': 'LangBot/' + langbot.__version__,
'originator': 'langbot',
'OpenAI-Beta': 'responses=experimental',
'Accept': 'text/event-stream' if stream else 'application/json',
}
@staticmethod
def _http_error(status):
if status == 401:
# Upstream authentication is not LangBot authentication: HTTP401 would
# make the browser discard its own valid user session.
return CodexProviderError(LOGIN_REQUIRED, 400, 'codex_reauthentication_required')
if status == 429:
return CodexProviderError(
'ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.',
429,
'codex_rate_limited',
)
if status == 403:
return CodexProviderError(
'ChatGPT denied this request. Check subscription and workspace permissions.',
403,
'codex_access_denied',
)
if status == 400:
return CodexProviderError(
'ChatGPT rejected the model or request. Check the selected model and request settings.',
400,
'codex_invalid_request',
)
return CodexProviderError('ChatGPT Codex upstream request failed. Please retry later.')
async def _response_error(self, response):
# Inspect only a bounded 429 error record and an allowlisted machine code.
# Never expose upstream prose, reset metadata, headers or credentials.
if response.status_code == 429:
payload = bytearray()
async for chunk in response.aiter_bytes():
if len(payload) + len(chunk) > 8192:
return self._http_error(429)
payload.extend(chunk)
try:
data = json.loads(payload)
error = data.get('error') if isinstance(data, dict) else None
if isinstance(error, dict) and (
error.get('type') == 'usage_limit_reached' or error.get('code') == 'usage_limit_reached'
):
return CodexProviderError(
'ChatGPT subscription usage limit reached. Please retry later or check your plan.',
429,
'codex_usage_limit_reached',
)
except (ValueError, UnicodeError):
pass
return self._http_error(response.status_code)
def _scope(self, query, model, tokens):
return (
id(query),
getattr(query, 'query_id', None),
model.model_entity.name,
tokens.get('connection_id'),
tokens['account_id'],
)
def _body(self, query, model, messages, funcs, extra_args, tokens):
args = {**(model.model_entity.extra_args or {}), **(extra_args or {})}
# Never permit credentials, transport overrides, store/history or arbitrary
# SDK kwargs to be smuggled through model advanced parameters.
allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'}
unknown = set(args) - allowed
if unknown:
raise ValueError('Unsupported Codex advanced parameters: ' + ', '.join(sorted(unknown)))
instructions = []
items = []
scope = self._scope(query, model, tokens)
for message in messages:
if message.role in ('system', 'developer'):
instructions.append('\n'.join(p['text'] for p in _content(message) if 'text' in p))
continue
if message.role == 'tool':
if not message.tool_call_id:
raise ValueError('Codex tool results require a tool_call_id')
output = (
message.content
if isinstance(message.content, str)
else json.dumps([p.model_dump(exclude_none=True) for p in message.content or []])
)
items.append({'type': 'function_call_output', 'call_id': message.tool_call_id, 'output': output or ''})
continue
if message.role not in ('assistant', 'user'):
raise ValueError('Unsupported Codex message role')
handle = (message.provider_specific_fields or {}).get('codex_replay_id')
cached = self._replay.get(handle) if isinstance(handle, str) else None
if query is not None and cached and cached[0] == scope and cached[1] > time.time():
items.extend(cached[2])
continue
content = _content(message)
if content:
items.append({'type': 'message', 'role': message.role, 'content': content})
for call in message.tool_calls or []:
items.append(
{
'type': 'function_call',
'call_id': call.id,
'name': call.function.name,
'arguments': call.function.arguments,
}
)
body = {
**args,
'model': model.model_entity.name,
'instructions': '\n\n'.join(instructions),
'input': items,
'store': False,
'stream': True,
'include': ['reasoning.encrypted_content'],
}
level = reasoning.normalize_reasoning_config(getattr(model.model_entity, 'reasoning_config', None))['level']
if level != 'provider_default':
reasoning.validate_reasoning_capabilities(
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
)
body['reasoning'] = {'effort': level, 'summary': 'auto'}
if funcs:
body['tools'] = [
{
'type': 'function',
'name': f.name,
'description': f.description,
'parameters': f.parameters,
'strict': False,
}
for f in funcs
]
return body
async def _events(self, query, model, messages, funcs, extra_args):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client:
for attempt in range(2):
body = self._body(query, model, messages, funcs, extra_args, tokens)
async with client.stream(
'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True)
) as response:
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
async for event in sse_events(response):
yield event, tokens
return
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None
async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out):
text = ''
seen_calls = set()
output_items = {}
response_id = None
async for event, tokens in self._events(query, model, messages, funcs, extra_args):
kind = event.get('type')
response = event.get('response') or {}
response_id = response.get('id') or response_id
if kind in ('error', 'response.failed', 'response.incomplete'):
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
if kind == 'response.output_text.delta':
delta = event.get('delta', '')
text += delta
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
if not remove_think:
yield pm.MessageChunk(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': event.get('delta', '')},
)
elif kind == 'response.output_item.done':
item = event.get('item') or {}
output_items[event.get('output_index', len(output_items))] = item
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
elif kind in ('response.completed', 'response.done'):
if response.get('status') not in (None, 'completed'):
raise CodexProviderError('ChatGPT Codex response was not completed')
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
# Some servers send only the terminal output, without text deltas.
final_text = ''.join(
p.get('text', '')
for item in output
if item.get('type') == 'message'
for p in item.get('content', [])
if p.get('type') == 'output_text'
)
if not text and final_text:
text = final_text
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
usage_out.update(_usage(response))
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
fields = None
if query is not None and output:
handle = secrets.token_urlsafe(24)
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
while len(self._replay) > 64:
self._replay.popitem(last=False)
fields = {'codex_replay_id': handle}
yield pm.MessageChunk(
role='assistant',
content='',
all_content=text,
is_final=True,
resp_message_id=response_id,
provider_specific_fields=fields,
)
return
raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.')
async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}):
yield chunk
async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
usage = {}
text = ''
calls = []
fields = {}
response_id = None
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, usage):
text += chunk.content or ''
calls.extend(chunk.tool_calls or [])
response_id = chunk.resp_message_id or response_id
for key, value in (chunk.provider_specific_fields or {}).items():
fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value
return pm.Message(
role='assistant',
content=text,
tool_calls=calls or None,
resp_message_id=response_id,
provider_specific_fields=fields or None,
), usage
async def scan_models(self, api_key=None):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
for attempt in range(2):
response = await client.get(
BASE_URL + '/models',
params={'client_version': langbot.__version__},
headers=self._headers(tokens),
)
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
data = response.json()
if not isinstance(data, dict) or not isinstance(data.get('models'), list):
raise ValueError('ChatGPT returned an invalid model catalog')
result = {}
for item in data['models']:
name = item.get('slug') or item.get('id')
if not isinstance(name, str) or not name or item.get('visibility') == 'hide':
continue
modalities = item.get('input_modalities') or ['text']
abilities = ['func_call']
if 'image' in modalities:
abilities.append('vision')
if item.get('supported_reasoning_levels'):
abilities.append('reasoning')
result[name] = {
'id': name,
'name': name,
'type': 'llm',
'abilities': abilities,
'display_name': item.get('display_name'),
'description': item.get('description'),
'context_length': item.get('context_window'),
'input_modalities': modalities,
'output_modalities': ['text'],
'owned_by': 'openai',
}
return {'models': list(result.values()), 'debug': None}
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT model discovery network error. Please retry.') from None
except (ValueError, TypeError, KeyError, AttributeError) as exc:
# Never echo upstream response bodies (which may contain credentials).
if isinstance(exc, ValueError) and str(exc).startswith(('ChatGPT', 'Codex')):
raise
raise ValueError('ChatGPT returned an invalid model catalog') from None
@@ -1,27 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: openai-codex
label:
en_US: OpenAI Codex
zh_Hans: OpenAI Codex
ja_JP: OpenAI Codex
icon: openai.svg
spec:
config:
- name: base_url
label:
en_US: ChatGPT endpoint
zh_Hans: ChatGPT 服务地址
ja_JP: ChatGPT エンドポイント
type: string
required: false
default: https://chatgpt.com/backend-api/codex
alias: "openai codex ChatGPT subscription OAuth 订阅"
support_type:
- llm
provider_category: manufacturer
execution:
python:
path: ./codex.py
attr: CodexRequester
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family in ('ollama', 'ollama_chat'):
elif family == 'ollama':
levels = ['provider_default']
levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
@@ -747,24 +747,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
converted_parts = []
for part in content:
if isinstance(part, dict) and part.get('type') == 'image_base64':
# History trimming (SessionManager) clears image_base64
# on past turns and exclude_none serialization drops
# the key entirely, so the replayed part may carry no
# payload. Prefer the base64 payload; fall back to an
# image_url that survived on the same element; drop
# hollow parts instead of raising KeyError (#2469).
image_b64 = part.get('image_base64')
fallback_url = None
if not image_b64:
raw_image_url = part.get('image_url')
if isinstance(raw_image_url, dict):
fallback_url = raw_image_url.get('url')
if image_b64 or fallback_url:
part['image_url'] = {'url': image_b64 or fallback_url}
part['type'] = 'image_url'
part.pop('image_base64', None)
else:
continue
part['image_url'] = {'url': part['image_base64']}
part['type'] = 'image_url'
del part['image_base64']
# OpenAI-compatible chat models reject non-image file parts
# (audio/document base64 or url). These originate from Voice /
# File attachments — including ones replayed from conversation
@@ -1345,14 +1330,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info."""
# litellm's embedding routing has no "ollama_chat" branch (that provider
# exists only for /api/chat completions) — embeddings still go through
# the plain "ollama" provider. Requesters configured for ollama_chat
# (to get native tool-calling on the chat path) must fall back to
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
# provider for this endpoint".
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
model_name = self._build_litellm_model_name(model.model_entity.name)
api_key = model.provider.token_mgr.get_token()
args = {
@@ -1548,12 +1526,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.get(models_url, headers=headers)
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
# Some OpenAI-compatible servers (notably a bare Ollama host,
# e.g. http://host:11434) expose the model list under /v1/models
# rather than /models. Providers whose configured base_url
# already ends in /v1 keep their original (working) URL.
response = await client.get(f'{base_url}/v1/models', headers=headers)
response.raise_for_status()
payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama
icon: ollama.svg
spec:
litellm_provider: ollama_chat
litellm_provider: ollama
config:
- name: base_url
label:
@@ -90,7 +90,7 @@ class LangflowAPIRunner(runner.RequestRunner):
}
# 如果配置中有tweaks,则添加到负载中
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks') or '{}')
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks'))
if tweaks:
payload['tweaks'] = tweaks
@@ -619,9 +619,7 @@ class LocalAgentRunner(runner.RequestRunner):
and len(func_ret) > 0
and isinstance(func_ret[0], provider_message.ContentElement)
):
# OpenAI-compatible APIs require tool-message content to be a
# string; a raw list of ContentElement causes HTTP 500 (#2457).
tool_content = '\n'.join(str(ce) for ce in func_ret)
tool_content = func_ret
else:
tool_content = json.dumps(func_ret, ensure_ascii=False)
+1 -13
View File
@@ -39,9 +39,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 获取输出键名,默认为response
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
self.response_handling = self.pipeline_config['ai']['n8n-service-api'].get('response-handling', 'reply')
if self.response_handling not in {'reply', 'ignore'}:
raise ValueError(f'Invalid n8n response-handling: {self.response_handling}')
# 获取认证类型,默认为none
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
@@ -265,11 +262,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
if self.response_handling == 'ignore':
status_ok = 200 <= response.status < 300
else:
status_ok = response.status == 200
if not status_ok:
if response.status != 200:
error_text = (
await httpclient.read_limited(
response,
@@ -279,11 +272,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
if self.response_handling == 'ignore':
response.release()
self.ap.logger.debug('n8n async webhook accepted; response body ignored')
return
async for chunk in self._process_response(response):
if is_stream:
yield chunk
+1 -56
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import dataclasses
import enum
import json
import math
@@ -207,13 +206,6 @@ class MCPSessionStatus(enum.Enum):
ERROR = 'error'
@dataclasses.dataclass(frozen=True)
class MCPOAuthChallenge:
"""Bearer challenge metadata returned by an OAuth-protected MCP server."""
resource_metadata_url: str | None
class _TransportReconnect(Exception):
"""Internal signal: the Box stdio WS transport dropped but the managed
process is still alive. Triggers a lightweight transport reconnect that
@@ -273,7 +265,6 @@ class RuntimeMCPSession:
_ready_event: asyncio.Event
error_message: str | None = None
_public_error_code: str = 'runtime_error'
error_phase: MCPSessionErrorPhase | None = None
@@ -519,13 +510,6 @@ class RuntimeMCPSession:
await self._init_streamable_http_server()
return
except Exception as e:
if self._extract_oauth_challenge(e) is not None:
self.error_phase = MCPSessionErrorPhase.OAUTH_REQUIRED
self.ap.logger.info(
f'MCP server {self.server_name}: remote server requires OAuth authorization; '
'not falling back to SSE'
)
raise
if not self._should_fallback_to_sse(e):
self.ap.logger.info(
f'MCP server {self.server_name}: Streamable HTTP transport failed '
@@ -646,7 +630,6 @@ class RuntimeMCPSession:
except Exception as e:
self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self._public_error_code = self._classify_public_error(e)
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
# handle retries first. It will set the event when all retries
@@ -769,11 +752,6 @@ class RuntimeMCPSession:
except Exception as e:
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
if self.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED:
self.retry_count = attempt + 1
self.status = MCPSessionStatus.ERROR
self._ready_event.set()
return
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and getattr(box_service, 'enabled', True):
@@ -854,39 +832,6 @@ class RuntimeMCPSession:
else:
yield exc
@staticmethod
def _classify_public_error(exc: BaseException) -> str:
"""Expose a safe category without transport URLs, headers, or arguments."""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
if isinstance(leaf, httpx.HTTPStatusError):
return f'http_{leaf.response.status_code}'
if isinstance(leaf, (httpx.TimeoutException, TimeoutError)):
return 'connection_timeout'
if isinstance(leaf, httpx.ConnectError):
return 'connection_unreachable'
return 'runtime_error'
@staticmethod
def _extract_oauth_challenge(exc: BaseException) -> MCPOAuthChallenge | None:
"""Extract an OAuth Bearer challenge from a remote MCP connection failure."""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
if not isinstance(leaf, httpx.HTTPStatusError) or leaf.response.status_code != 401:
continue
for header in leaf.response.headers.get_list('www-authenticate'):
bearer_match = re.search(r'(?:^|,)\s*Bearer(?:\s|,|$)', header, flags=re.IGNORECASE)
if bearer_match is None:
continue
metadata_match = re.search(
r'(?:^|,)\s*resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))',
header[bearer_match.end() :],
flags=re.IGNORECASE,
)
if metadata_match is None:
continue
resource_metadata_url = metadata_match.group(1) or metadata_match.group(2)
return MCPOAuthChallenge(resource_metadata_url=resource_metadata_url)
return None
@staticmethod
def _should_fallback_to_sse(exc: BaseException) -> bool:
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
@@ -1429,7 +1374,7 @@ class RuntimeMCPSession:
# environment values. Detailed diagnostics belong in AUDIT_VIEW
# logs; resource-list responses expose only a stable status.
'error_message': 'MCP runtime failed' if self.error_message else None,
'error_code': self._public_error_code if self.error_message else None,
'error_code': 'runtime_error' if self.error_message else None,
'error_phase': self.error_phase.value if self.error_phase else None,
'retry_count': self.retry_count,
'tool_count': len(self.get_tools()),

Some files were not shown because too many files have changed in this diff Show More