mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-10 11:57:20 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fe80eaf64 | |||
| ce6b647fe7 | |||
| 485113ae43 | |||
| 1ba3c1ec72 | |||
| 3a82aa5fcc | |||
| fc1c998434 | |||
| d90253cc77 | |||
| d8b3dad212 | |||
| 8281eb18c9 | |||
| 4eea3419e8 | |||
| 814740ea68 | |||
| e6e8258545 | |||
| 1a69747a06 | |||
| 1fa5e2f755 | |||
| 267232c24f | |||
| d6443b10bc | |||
| 0577689da4 | |||
| bc32eb3ca0 | |||
| 0f216a0d4d | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 |
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -22,7 +22,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
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
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
|
|||||||
## Where to Look
|
## Where to Look
|
||||||
|
|
||||||
- Architecture map: `ARCHITECTURE.md`.
|
- Architecture map: `ARCHITECTURE.md`.
|
||||||
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
|
||||||
- API-key auth: `docs/API_KEY_AUTH.md`.
|
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||||
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
- 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.
|
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</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.
|
- **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.
|
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||||
|
|
||||||
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
[→ Learn more about all features](https://langbot.app/docs/en/insight/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/).
|
📍 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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">扩展市场</a> |
|
<a href="https://space.langbot.app">扩展市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/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/)。
|
📍 实践指南:[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
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [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)
|
**更多方式:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ docker compose --profile all up -d
|
|||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</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.
|
- **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.
|
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/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/).
|
📍 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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</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.
|
- **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.
|
- **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://link.langbot.app/en/docs/features)
|
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/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/).
|
📍 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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
<a href="https://langbot.app/docs/ja/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/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/)。
|
📍 実践ガイド: [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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**その他:** [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)
|
**その他:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/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/).
|
📍 실전 가이드: [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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**더 많은 옵션:** [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)
|
**더 많은 옵션:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/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/).
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Другие варианты:** [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)
|
**Другие варианты:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">外掛市場</a> |
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/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/)。
|
📍 實踐指南:[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
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [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)
|
**更多方式:** [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Trang chủ</a> |
|
<a href="https://langbot.app">Trang chủ</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</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.
|
- **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ệ.
|
- **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://link.langbot.app/en/docs/features)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/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/).
|
📍 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
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**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)
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Docker Compose configuration for LangBot
|
# Docker Compose configuration for LangBot
|
||||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Kubernetes Deployment for LangBot
|
# Kubernetes Deployment for LangBot
|
||||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||||
#
|
#
|
||||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ Each endpoint accepts **either**:
|
|||||||
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
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
|
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
|
## Example: Model Management
|
||||||
|
|
||||||
### List All LLM Models
|
### List All LLM Models
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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 工作空间内的资源,连接个人账号前请确认模型的使用范围。
|
||||||
@@ -218,8 +218,8 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
categories: [popular, global]
|
categories: [popular, global]
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/en/platforms/http-bot
|
||||||
config:
|
config:
|
||||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||||
- { name: callback_url, type: string, required: false, default: "" }
|
- { name: callback_url, type: string, required: false, default: "" }
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ For large datasets:
|
|||||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||||
- OceanBase Documentation: https://oceanbase.ai
|
- OceanBase Documentation: https://oceanbase.ai
|
||||||
- LangBot Documentation: https://docs.langbot.app
|
- LangBot Documentation: https://langbot.app/docs
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -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
|
They show the whole loop: signing a request, pushing a message, and receiving
|
||||||
multi-part replies on a callback endpoint.
|
multi-part replies on a callback endpoint.
|
||||||
|
|
||||||
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
|
||||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||||
1→M 的多段回复。
|
1→M 的多段回复。
|
||||||
|
|
||||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
|
||||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||||
|
|
||||||
## 文件清单
|
## 文件清单
|
||||||
|
|||||||
@@ -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
|
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||||
a single `<script>` tag.
|
a single `<script>` tag.
|
||||||
|
|
||||||
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||||
网站上的那个组件。
|
网站上的那个组件。
|
||||||
|
|
||||||
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
|
||||||
|
|
||||||
## 文件清单
|
## 文件清单
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.8"
|
version = "4.10.10"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.5.5",
|
"langbot-plugin==0.5.7",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -114,7 +114,7 @@ seekdb = [
|
|||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://langbot.app/docs"
|
||||||
Repository = "https://github.com/langbot-app/LangBot"
|
Repository = "https://github.com/langbot-app/LangBot"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
|||||||
## Kubernetes
|
## Kubernetes
|
||||||
|
|
||||||
See `docker/kubernetes.yaml` and the deployment guide at
|
See `docker/kubernetes.yaml` and the deployment guide at
|
||||||
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
|
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
|
||||||
|
|
||||||
## config.yaml (generated at `data/config.yaml` on first run)
|
## config.yaml (generated at `data/config.yaml` on first run)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Two kinds of key are accepted:
|
|||||||
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
|
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
|
||||||
scopes do not authorize a tool gets `403 Forbidden`.
|
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
|
## Client configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -86,6 +88,38 @@ already have a default pipeline.
|
|||||||
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
|
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
|
||||||
`delete_*` as needed.
|
`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)
|
## Implementation & maintenance (for LangBot developers)
|
||||||
|
|
||||||
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
|
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
|||||||
|___/
|
|___/
|
||||||
|
|
||||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||||
📖 Documentation 文档地址: https://docs.langbot.app
|
📖 Documentation 文档地址: https://langbot.app/docs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
template_params = dict(card_param_map or {})
|
||||||
if card_data_config is not None:
|
if card_data_config is not None:
|
||||||
cardData['config'] = json.dumps(card_data_config)
|
template_params['config'] = card_data_config
|
||||||
|
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||||
|
|
||||||
body: dict = {
|
body: dict = {
|
||||||
'cardTemplateId': card_template_id,
|
'cardTemplateId': card_template_id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
|
|||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from ....cloud.entitlements import EntitlementUnavailableError
|
from ....cloud.entitlements import EntitlementUnavailableError
|
||||||
from ....core.errors import TaskCapacityError
|
from ....core.errors import TaskCapacityError
|
||||||
|
from ....provider.modelmgr.codex_errors import CodexProviderError
|
||||||
from ..authz import (
|
from ..authz import (
|
||||||
AuthenticationDeniedError,
|
AuthenticationDeniedError,
|
||||||
AuthorizationError,
|
AuthorizationError,
|
||||||
@@ -247,6 +248,8 @@ class RouterGroup(abc.ABC):
|
|||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
except Exception as e: # 自动 500
|
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):
|
if isinstance(e, AuthorizationError):
|
||||||
return self.http_status(e.status_code, e.error_code, str(e))
|
return self.http_status(e.status_code, e.error_code, str(e))
|
||||||
if isinstance(e, WorkspaceNotFoundError):
|
if isinstance(e, WorkspaceNotFoundError):
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
start_time_str = quart.request.args.get('startTime')
|
start_time_str = quart.request.args.get('startTime')
|
||||||
end_time_str = quart.request.args.get('endTime')
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
user_query = quart.request.args.get('userQuery')
|
||||||
is_active_str = quart.request.args.get('isActive')
|
is_active_str = quart.request.args.get('isActive')
|
||||||
limit = int(quart.request.args.get('limit', 100))
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
|
user_query=user_query,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||||
"""Get detailed analysis for a specific session"""
|
"""Get detailed analysis for a specific session"""
|
||||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
# Always return success with the analysis data
|
# Always return success with the analysis data
|
||||||
# The frontend will handle the 'found: false' case
|
# The frontend will handle the 'found: false' case
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import quart
|
|||||||
from ....authz import Permission, has_permission
|
from ....authz import Permission, has_permission
|
||||||
from ....context import RequestContext
|
from ....context import RequestContext
|
||||||
from ... import group
|
from ... import group
|
||||||
|
from .query import resolve_include_secret
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||||
@@ -16,7 +17,12 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
provider_uuid = quart.request.args.get('provider_uuid')
|
provider_uuid = quart.request.args.get('provider_uuid')
|
||||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
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)
|
||||||
if provider_uuid:
|
if provider_uuid:
|
||||||
models = await self.ap.llm_model_service.get_llm_models_by_provider(
|
models = await self.ap.llm_model_service.get_llm_models_by_provider(
|
||||||
request_context,
|
request_context,
|
||||||
@@ -53,10 +59,16 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
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(
|
model = await self.ap.llm_model_service.get_llm_model(
|
||||||
request_context,
|
request_context,
|
||||||
model_uuid,
|
model_uuid,
|
||||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
include_secret=include_secret,
|
||||||
)
|
)
|
||||||
if model is None:
|
if model is None:
|
||||||
return self.http_status(404, -1, 'model not found')
|
return self.http_status(404, -1, 'model not found')
|
||||||
@@ -111,7 +123,12 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
provider_uuid = quart.request.args.get('provider_uuid')
|
provider_uuid = quart.request.args.get('provider_uuid')
|
||||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
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)
|
||||||
if provider_uuid:
|
if provider_uuid:
|
||||||
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||||
request_context,
|
request_context,
|
||||||
@@ -148,10 +165,16 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
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(
|
model = await self.ap.embedding_models_service.get_embedding_model(
|
||||||
request_context,
|
request_context,
|
||||||
model_uuid,
|
model_uuid,
|
||||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
include_secret=include_secret,
|
||||||
)
|
)
|
||||||
if model is None:
|
if model is None:
|
||||||
return self.http_status(404, -1, 'model not found')
|
return self.http_status(404, -1, 'model not found')
|
||||||
@@ -208,7 +231,12 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
provider_uuid = quart.request.args.get('provider_uuid')
|
provider_uuid = quart.request.args.get('provider_uuid')
|
||||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
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)
|
||||||
if provider_uuid:
|
if provider_uuid:
|
||||||
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
|
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
|
||||||
request_context,
|
request_context,
|
||||||
@@ -245,10 +273,16 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
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(
|
model = await self.ap.rerank_models_service.get_rerank_model(
|
||||||
request_context,
|
request_context,
|
||||||
model_uuid,
|
model_uuid,
|
||||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
include_secret=include_secret,
|
||||||
)
|
)
|
||||||
if model is None:
|
if model is None:
|
||||||
return self.http_status(404, -1, 'model not found')
|
return self.http_status(404, -1, 'model not found')
|
||||||
|
|||||||
@@ -3,11 +3,86 @@ import quart
|
|||||||
from ....authz import Permission, has_permission
|
from ....authz import Permission, has_permission
|
||||||
from ....context import RequestContext
|
from ....context import RequestContext
|
||||||
from ... import group
|
from ... import group
|
||||||
|
from .query import resolve_include_secret
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('models/providers', '/api/v1/provider/providers')
|
@group.group_class('models/providers', '/api/v1/provider/providers')
|
||||||
class ModelProvidersRouterGroup(group.RouterGroup):
|
class ModelProvidersRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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(
|
@self.route(
|
||||||
'',
|
'',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
@@ -15,9 +90,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
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(
|
providers = await self.ap.provider_service.get_providers(
|
||||||
request_context,
|
request_context,
|
||||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
include_secret=include_secret,
|
||||||
)
|
)
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
|
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
|
||||||
@@ -47,10 +128,16 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
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(
|
provider = await self.ap.provider_service.get_provider(
|
||||||
request_context,
|
request_context,
|
||||||
provider_uuid,
|
provider_uuid,
|
||||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
include_secret=include_secret,
|
||||||
)
|
)
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return self.http_status(404, -1, 'provider not found')
|
return self.http_status(404, -1, 'provider not found')
|
||||||
@@ -82,7 +169,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||||
try:
|
try:
|
||||||
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
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)
|
||||||
return self.success()
|
return self.success()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return self.http_status(400, -1, str(e))
|
return self.http_status(400, -1, str(e))
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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,14 +7,116 @@ from .. import group
|
|||||||
from .....utils import constants
|
from .....utils import constants
|
||||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||||
from ...authz import Permission
|
from ...authz import Permission
|
||||||
from ...context import RequestContext
|
from ...context import PrincipalType, RequestContext
|
||||||
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
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')
|
@group.group_class('system', '/api/v1/system')
|
||||||
class SystemRouterGroup(group.RouterGroup):
|
class SystemRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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)
|
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
# Read wizard_status and wizard_progress from metadata table
|
# Read wizard_status and wizard_progress from metadata table
|
||||||
@@ -223,7 +325,7 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
auth_type=group.AuthType.USER_TOKEN,
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
@@ -242,18 +344,23 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
instance_uuid=request_context.instance_uuid,
|
instance_uuid=request_context.instance_uuid,
|
||||||
workspace_uuid=request_context.workspace_uuid,
|
workspace_uuid=request_context.workspace_uuid,
|
||||||
placement_generation=request_context.placement_generation,
|
placement_generation=request_context.placement_generation,
|
||||||
|
public=request_context.principal.principal_type == PrincipalType.API_KEY,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks/<task_id>',
|
'/tasks/<task_id>',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
auth_type=group.AuthType.USER_TOKEN,
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(task_id: str, request_context: RequestContext) -> str:
|
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 = self.ap.task_mgr.get_task_by_id(
|
||||||
int(task_id),
|
task_index,
|
||||||
instance_uuid=request_context.instance_uuid,
|
instance_uuid=request_context.instance_uuid,
|
||||||
workspace_uuid=request_context.workspace_uuid,
|
workspace_uuid=request_context.workspace_uuid,
|
||||||
placement_generation=request_context.placement_generation,
|
placement_generation=request_context.placement_generation,
|
||||||
@@ -262,6 +369,8 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
if task is None:
|
if task is None:
|
||||||
return self.http_status(404, 404, 'Task not found')
|
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())
|
return self.success(data=task.to_dict())
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import quart
|
|||||||
import argon2
|
import argon2
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
@@ -11,6 +13,33 @@ from ...context import RequestContext
|
|||||||
from .....cloud.launch import SpaceLaunchError
|
from .....cloud.launch import SpaceLaunchError
|
||||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
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')
|
@group.group_class('user', '/api/v1/user')
|
||||||
class UserRouterGroup(group.RouterGroup):
|
class UserRouterGroup(group.RouterGroup):
|
||||||
@@ -81,6 +110,12 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
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
|
json_data = await quart.request.json
|
||||||
|
|
||||||
user_email = json_data['user']
|
user_email = json_data['user']
|
||||||
@@ -98,7 +133,18 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
return self.http_status(400, -1, 'User not found')
|
return self.http_status(400, -1, 'User not found')
|
||||||
|
|
||||||
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
|
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:
|
||||||
return self.http_status(403, -1, 'Invalid recovery key')
|
return self.http_status(403, -1, 'Invalid recovery key')
|
||||||
|
|
||||||
await self.ap.user_service.reset_password(user_email, new_password)
|
await self.ap.user_service.reset_password(user_email, new_password)
|
||||||
@@ -186,6 +232,9 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
code = json_data.get('code')
|
code = json_data.get('code')
|
||||||
state = json_data.get('state')
|
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')
|
launch_assertion = json_data.get('launch_assertion')
|
||||||
workspace_uuid = json_data.get('workspace_uuid')
|
workspace_uuid = json_data.get('workspace_uuid')
|
||||||
|
|
||||||
@@ -199,8 +248,11 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(1, 'Missing authorization code')
|
return self.fail(1, 'Missing authorization code')
|
||||||
if not state:
|
if not state:
|
||||||
return self.fail(1, 'Missing state parameter')
|
return self.fail(1, 'Missing state parameter')
|
||||||
|
if not str(code).startswith('v4_'):
|
||||||
|
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||||
|
|
||||||
try:
|
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')
|
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||||
@@ -218,24 +270,36 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
code,
|
code,
|
||||||
workspace_uuids,
|
workspace_uuids,
|
||||||
workspace_created_ats,
|
workspace_created_ats,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
return self.fail(1, 'Failed to get access token from Space')
|
return self.fail(1, 'Failed to get access token from Space')
|
||||||
|
|
||||||
# Authenticate and create/update local user
|
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.
|
||||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||||
access_token, refresh_token, expires_in
|
access_token, refresh_token, expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
if launch_workspace_uuid:
|
if target_workspace_uuid:
|
||||||
try:
|
try:
|
||||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||||
user_obj.uuid,
|
user_obj.uuid,
|
||||||
launch_workspace_uuid,
|
target_workspace_uuid,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||||
@@ -367,12 +431,17 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
code = json_data.get('code')
|
code = json_data.get('code')
|
||||||
state = json_data.get('state')
|
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:
|
if not code:
|
||||||
return self.http_status(400, -1, 'Missing authorization code')
|
return self.http_status(400, -1, 'Missing authorization code')
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
return self.http_status(400, -1, 'Missing state parameter')
|
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:
|
try:
|
||||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||||
@@ -385,7 +454,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
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
|
||||||
|
)
|
||||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
@@ -428,6 +500,10 @@ 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'])
|
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||||
if account is None:
|
if account is None:
|
||||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||||
|
|||||||
@@ -137,7 +137,16 @@ class BotService:
|
|||||||
|
|
||||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||||
|
|
||||||
await self.ap.platform_mgr.load_bot(context, bot)
|
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
|
||||||
|
|
||||||
return bot_data['uuid']
|
return bot_data['uuid']
|
||||||
|
|
||||||
|
|||||||
@@ -446,15 +446,19 @@ class MCPService:
|
|||||||
persisted_session = runtime_mcp_session
|
persisted_session = runtime_mcp_session
|
||||||
|
|
||||||
async def _refresh_and_report() -> None:
|
async def _refresh_and_report() -> None:
|
||||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
try:
|
||||||
if needs_start:
|
needs_start = (
|
||||||
await persisted_session.start()
|
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||||
else:
|
)
|
||||||
try:
|
if needs_start:
|
||||||
await persisted_session.refresh()
|
|
||||||
except Exception:
|
|
||||||
await persisted_session.start()
|
await persisted_session.start()
|
||||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
else:
|
||||||
|
try:
|
||||||
|
await persisted_session.refresh()
|
||||||
|
except Exception:
|
||||||
|
await persisted_session.start()
|
||||||
|
finally:
|
||||||
|
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||||
|
|
||||||
coroutine = _refresh_and_report()
|
coroutine = _refresh_and_report()
|
||||||
else:
|
else:
|
||||||
@@ -471,8 +475,11 @@ class MCPService:
|
|||||||
async def _run_and_cleanup() -> None:
|
async def _run_and_cleanup() -> None:
|
||||||
try:
|
try:
|
||||||
await test_session.start()
|
await test_session.start()
|
||||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
|
||||||
finally:
|
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()
|
||||||
try:
|
try:
|
||||||
await test_session.shutdown()
|
await test_session.shutdown()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1257,6 +1257,7 @@ class MonitoringService:
|
|||||||
pipeline_ids: list[str] | None = None,
|
pipeline_ids: list[str] | None = None,
|
||||||
start_time: datetime.datetime | None = None,
|
start_time: datetime.datetime | None = None,
|
||||||
end_time: datetime.datetime | None = None,
|
end_time: datetime.datetime | None = None,
|
||||||
|
user_query: str | None = None,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -1274,6 +1275,14 @@ class MonitoringService:
|
|||||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||||
if end_time:
|
if end_time:
|
||||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= 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:
|
if is_active is not None:
|
||||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||||
|
|
||||||
@@ -1365,6 +1374,8 @@ class MonitoringService:
|
|||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get bounded session details with full statistics computed in SQL."""
|
"""Get bounded session details with full statistics computed in SQL."""
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
@@ -1478,12 +1489,17 @@ class MonitoringService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
tool_stats = tool_stats_result.one()
|
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 = (
|
tool_query = (
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||||
.where(
|
.where(*tool_conditions)
|
||||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
|
||||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
|
||||||
)
|
|
||||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||||
.limit(detail_limit + 1)
|
.limit(detail_limit + 1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -7,8 +8,10 @@ import sqlalchemy
|
|||||||
|
|
||||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
from ....core import app
|
from ....core import app
|
||||||
|
from ....core.task_boundary import create_detached_task
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
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 .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
|
||||||
@@ -20,6 +23,8 @@ class ModelProviderService:
|
|||||||
|
|
||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
self.codex_auth = CodexAuth(ap)
|
||||||
|
self._deletion_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
def _is_cloud_runtime(self) -> bool:
|
def _is_cloud_runtime(self) -> bool:
|
||||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
||||||
@@ -116,14 +121,30 @@ class ModelProviderService:
|
|||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
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['uuid'] = str(uuid.uuid4())
|
||||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(
|
provider_data['api_keys'] = self._normalize_api_keys(
|
||||||
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
||||||
)
|
)
|
||||||
await self.ap.persistence_mgr.execute_async(
|
if provider_data.get('requester') == CODEX_REQUESTER:
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
# load to runtime
|
# load to runtime
|
||||||
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
|
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
|
||||||
@@ -138,6 +159,17 @@ class ModelProviderService:
|
|||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||||
provider_data.pop('uuid', None)
|
provider_data.pop('uuid', None)
|
||||||
provider_data.pop('workspace_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:
|
if 'api_keys' in provider_data:
|
||||||
submitted_keys = provider_data.get('api_keys')
|
submitted_keys = provider_data.get('api_keys')
|
||||||
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
||||||
@@ -163,60 +195,107 @@ class ModelProviderService:
|
|||||||
raise WorkspaceNotFoundError('Provider not found')
|
raise WorkspaceNotFoundError('Provider not found')
|
||||||
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
||||||
|
|
||||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
|
||||||
"""Delete a provider (only if no models reference it)"""
|
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
|
||||||
await self._assert_provider_mutable(context, provider_uuid)
|
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
# Check if any models use this provider
|
persistence = self.ap.persistence_mgr
|
||||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
model_types = (
|
||||||
scope_statement(
|
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
|
||||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
|
||||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
|
||||||
),
|
|
||||||
persistence_model.LLMModel,
|
|
||||||
workspace_uuid,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if llm_result.first() is not None:
|
deleted_models: list[tuple[str, list[str]]] = []
|
||||||
raise ValueError('Cannot delete provider: LLM models still reference it')
|
async with persistence.tenant_uow(workspace_uuid):
|
||||||
|
# Check ownership before touching children. Lock the provider on PostgreSQL
|
||||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
# so concurrent model inserts cannot race the reference check/deletion.
|
||||||
scope_statement(
|
provider_result = await persistence.execute_async(
|
||||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
scope_statement(
|
||||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
sqlalchemy.select(persistence_model.ModelProvider.requester)
|
||||||
),
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
persistence_model.EmbeddingModel,
|
.with_for_update(),
|
||||||
workspace_uuid,
|
persistence_model.ModelProvider,
|
||||||
|
workspace_uuid,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
provider = provider_result.first()
|
||||||
if embedding_result.first() is not None:
|
if provider is None:
|
||||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
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')
|
||||||
|
|
||||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
for model_type, label, remover in model_types:
|
||||||
scope_statement(
|
result = await persistence.execute_async(
|
||||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
scope_statement(
|
||||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
|
||||||
),
|
model_type,
|
||||||
persistence_model.RerankModel,
|
workspace_uuid,
|
||||||
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,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
result = await persistence.execute_async(
|
||||||
if rerank_result.first() is not None:
|
scope_statement(
|
||||||
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
),
|
||||||
scope_statement(
|
persistence_model.ModelProvider,
|
||||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
workspace_uuid,
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
)
|
||||||
),
|
|
||||||
persistence_model.ModelProvider,
|
|
||||||
workspace_uuid,
|
|
||||||
)
|
)
|
||||||
)
|
if result.rowcount == 0:
|
||||||
if getattr(result, 'rowcount', None) == 0:
|
raise WorkspaceNotFoundError('Provider not found')
|
||||||
raise WorkspaceNotFoundError('Provider not found')
|
|
||||||
|
|
||||||
await self.ap.model_mgr.remove_provider(context, provider_uuid)
|
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)
|
||||||
|
|
||||||
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
|
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
|
||||||
"""Get count of models using this provider"""
|
"""Get count of models using this provider"""
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class SpaceService:
|
|||||||
|
|
||||||
space_config = self._get_space_config()
|
space_config = self._get_space_config()
|
||||||
authorize_url = space_config['oauth_authorize_url']
|
authorize_url = space_config['oauth_authorize_url']
|
||||||
params = {'redirect_uri': redirect_uri}
|
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||||
if state:
|
if state:
|
||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
@@ -129,6 +129,8 @@ class SpaceService:
|
|||||||
code: str,
|
code: str,
|
||||||
workspace_uuids: list[str] | None = None,
|
workspace_uuids: list[str] | None = None,
|
||||||
workspace_created_ats: dict[str, int] | None = None,
|
workspace_created_ats: dict[str, int] | None = None,
|
||||||
|
*,
|
||||||
|
redirect_uri: str = '',
|
||||||
) -> typing.Dict:
|
) -> typing.Dict:
|
||||||
"""Exchange OAuth authorization code for tokens"""
|
"""Exchange OAuth authorization code for tokens"""
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
@@ -141,6 +143,7 @@ class SpaceService:
|
|||||||
f'{space_url}/api/v1/accounts/oauth/token',
|
f'{space_url}/api/v1/accounts/oauth/token',
|
||||||
json={
|
json={
|
||||||
'code': code,
|
'code': code,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
# Sending an explicit empty list tells new Space servers not to
|
# Sending an explicit empty list tells new Space servers not to
|
||||||
# synthesize a legacy instance-derived Workspace binding.
|
# synthesize a legacy instance-derived Workspace binding.
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ class UserService:
|
|||||||
f'email:{normalized_email}',
|
f'email:{normalized_email}',
|
||||||
)
|
)
|
||||||
|
|
||||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||||
"""Bind Space account to existing local account"""
|
"""Bind Space account to existing local account"""
|
||||||
local_account = await self.get_user_by_email(user_email)
|
local_account = await self.get_user_by_email(user_email)
|
||||||
if local_account is None:
|
if local_account is None:
|
||||||
@@ -794,12 +794,13 @@ class UserService:
|
|||||||
code,
|
code,
|
||||||
[binding.workspace_uuid],
|
[binding.workspace_uuid],
|
||||||
{binding.workspace_uuid: created_ts},
|
{binding.workspace_uuid: created_ts},
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Compatibility for early/bootstrap call sites that have not wired
|
# Compatibility for early/bootstrap call sites that have not wired
|
||||||
# WorkspaceService yet; old Space servers still derive the legacy
|
# WorkspaceService yet; old Space servers still derive the legacy
|
||||||
# Workspace identity from instance_id when the field is omitted.
|
# Workspace identity from instance_id when the field is omitted.
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
|||||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
|||||||
# The database cursor remains the shared projection high-water mark,
|
# The database cursor remains the shared projection high-water mark,
|
||||||
# while this cursor tracks what this process has actually observed.
|
# while this cursor tracks what this process has actually observed.
|
||||||
self._consumer_cursor: int | None = None
|
self._consumer_cursor: int | None = None
|
||||||
|
self._sync_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
"""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
|
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||||
for _attempt in range(5):
|
for _attempt in range(5):
|
||||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||||
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
|
|||||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||||
|
|
||||||
async def sync_once(self) -> None:
|
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
|
cursor = self._consumer_cursor
|
||||||
if cursor is None:
|
if cursor is None:
|
||||||
await self.initialize()
|
await self._refresh_snapshot()
|
||||||
return
|
return
|
||||||
batch = await self.provider.fetch_events(
|
batch = await self.provider.fetch_events(
|
||||||
self.instance_uuid,
|
self.instance_uuid,
|
||||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
|||||||
for row in inbox_rows:
|
for row in inbox_rows:
|
||||||
row.applied_at = now
|
row.applied_at = now
|
||||||
|
|
||||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
async def _apply_accounts(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
snapshot: DirectorySnapshot,
|
||||||
|
*,
|
||||||
|
preserve_existing: bool = False,
|
||||||
|
) -> dict[str, User]:
|
||||||
selected: dict[str, DirectoryMember] = {}
|
selected: dict[str, DirectoryMember] = {}
|
||||||
emails: dict[str, str] = {}
|
emails: dict[str, str] = {}
|
||||||
for workspace in snapshot.workspaces:
|
for workspace in snapshot.workspaces:
|
||||||
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
|
|||||||
continue
|
continue
|
||||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
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:
|
if account.projection_revision > snapshot.cursor:
|
||||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||||
projected_account = self._account_projection(member)
|
projected_account = self._account_projection(member)
|
||||||
|
|||||||
@@ -635,9 +635,9 @@ class Application:
|
|||||||
frontend_path = paths.get_frontend_path()
|
frontend_path = paths.get_frontend_path()
|
||||||
|
|
||||||
if not os.path.exists(frontend_path):
|
if not os.path.exists(frontend_path):
|
||||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
|
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
|
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from .. import stage, app
|
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')
|
@stage.stage_class('GenKeysStage')
|
||||||
class GenKeysStage(stage.BootingStage):
|
class GenKeysStage(stage.BootingStage):
|
||||||
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
|
|||||||
ap.instance_config.data['system']['recovery_key'] = ''
|
ap.instance_config.data['system']['recovery_key'] = ''
|
||||||
|
|
||||||
if not ap.instance_config.data['system']['recovery_key']:
|
if not ap.instance_config.data['system']['recovery_key']:
|
||||||
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
|
# 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)
|
||||||
|
)
|
||||||
await ap.instance_config.dump_config()
|
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)'
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import typing
|
import typing
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
@@ -197,6 +198,41 @@ 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):
|
def cancel(self):
|
||||||
self.task.cancel()
|
self.task.cancel()
|
||||||
|
|
||||||
@@ -325,19 +361,20 @@ class AsyncTaskManager:
|
|||||||
instance_uuid: str | None = None,
|
instance_uuid: str | None = None,
|
||||||
workspace_uuid: str | None = None,
|
workspace_uuid: str | None = None,
|
||||||
placement_generation: int | None = None,
|
placement_generation: int | None = None,
|
||||||
|
public: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
tasks = [
|
||||||
'tasks': [
|
t.to_public_dict() if public else t.to_dict()
|
||||||
t.to_dict()
|
for t in self.tasks
|
||||||
for t in self.tasks
|
if (type is None or t.task_type == type)
|
||||||
if (type is None or t.task_type == type)
|
and (kind is None or t.kind == kind)
|
||||||
and (kind is None or t.kind == kind)
|
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
]
|
||||||
],
|
if public:
|
||||||
'id_index': TaskWrapper._id_index,
|
return {'tasks': tasks}
|
||||||
}
|
return {'tasks': tasks, 'id_index': TaskWrapper._id_index}
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
def get_stats(self) -> dict:
|
||||||
completed = sum(1 for t in self.tasks if t.task.done())
|
completed = sum(1 for t in self.tasks if t.task.done())
|
||||||
|
|||||||
@@ -33,6 +33,28 @@ 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):
|
class LLMModel(Base):
|
||||||
"""LLM model"""
|
"""LLM model"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""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')
|
||||||
@@ -62,6 +62,7 @@ _ALEMBIC_TENANT_TABLES = {
|
|||||||
'binary_storages',
|
'binary_storages',
|
||||||
'mcp_servers',
|
'mcp_servers',
|
||||||
'model_providers',
|
'model_providers',
|
||||||
|
'codex_credentials',
|
||||||
'llm_models',
|
'llm_models',
|
||||||
'embedding_models',
|
'embedding_models',
|
||||||
'rerank_models',
|
'rerank_models',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
|||||||
'binary_storages': 'workspace_uuid',
|
'binary_storages': 'workspace_uuid',
|
||||||
'mcp_servers': 'workspace_uuid',
|
'mcp_servers': 'workspace_uuid',
|
||||||
'model_providers': 'workspace_uuid',
|
'model_providers': 'workspace_uuid',
|
||||||
|
'codex_credentials': 'workspace_uuid',
|
||||||
'llm_models': 'workspace_uuid',
|
'llm_models': 'workspace_uuid',
|
||||||
'embedding_models': 'workspace_uuid',
|
'embedding_models': 'workspace_uuid',
|
||||||
'rerank_models': 'workspace_uuid',
|
'rerank_models': 'workspace_uuid',
|
||||||
@@ -852,7 +853,30 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
|
|||||||
self._require_owner_task()
|
self._require_owner_task()
|
||||||
self._enter_internal_access()
|
self._enter_internal_access()
|
||||||
try:
|
try:
|
||||||
await transaction.commit()
|
# 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
|
||||||
finally:
|
finally:
|
||||||
self._exit_internal_access()
|
self._exit_internal_access()
|
||||||
|
|
||||||
@@ -1369,6 +1393,7 @@ class TenantUnitOfWork:
|
|||||||
state.mark_rollback_only(exc_value)
|
state.mark_rollback_only(exc_value)
|
||||||
rollback_only = state.rollback_only
|
rollback_only = state.rollback_only
|
||||||
committed = False
|
committed = False
|
||||||
|
transaction_error: BaseException | None = None
|
||||||
try:
|
try:
|
||||||
if exc_type is None and not rollback_only:
|
if exc_type is None and not rollback_only:
|
||||||
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
||||||
@@ -1381,6 +1406,9 @@ class TenantUnitOfWork:
|
|||||||
_UOW_SESSION_CONTROL_CAPABILITY,
|
_UOW_SESSION_CONTROL_CAPABILITY,
|
||||||
transaction,
|
transaction,
|
||||||
)
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
transaction_error = exc
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
if self._active_transaction is not None and self._context_token is not None:
|
if self._active_transaction is not None and self._context_token is not None:
|
||||||
@@ -1388,6 +1416,10 @@ class TenantUnitOfWork:
|
|||||||
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
||||||
_UOW_SESSION_CONTROL_CAPABILITY
|
_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:
|
finally:
|
||||||
if self._database_operation_token is not None:
|
if self._database_operation_token is not None:
|
||||||
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/discord
|
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||||
en: https://link.langbot.app/en/platforms/discord
|
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||||
ja: https://link.langbot.app/ja/platforms/discord
|
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,587 @@
|
|||||||
|
"""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}')
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/en/platforms/http-bot
|
||||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/kook
|
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||||
en: https://link.langbot.app/en/platforms/kook
|
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||||
ja: https://link.langbot.app/ja/platforms/kook
|
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
|||||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
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:
|
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||||
field_type = _dify_field_type(field)
|
field_type = _dify_field_type(field)
|
||||||
if field_type == 'file':
|
if field_type == 'file':
|
||||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
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 {})
|
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||||
else:
|
else:
|
||||||
# Normal finish: keep pre-pause + resume content visible,
|
# Normal finish: remove buttons/notice and finalize the card.
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
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,
|
||||||
|
)
|
||||||
await self._update_card_layout(
|
await self._update_card_layout(
|
||||||
card_id=card_id,
|
card_id=card_id,
|
||||||
message_source=message_source,
|
message_source=message_source,
|
||||||
text_message=pre_pause,
|
text_message=main_text,
|
||||||
sequence=final_seq,
|
sequence=final_seq,
|
||||||
form_data=None,
|
form_data=None,
|
||||||
notice_text=selected_notice if resume_from else '',
|
notice_text=selected_notice if resume_from else '',
|
||||||
resume_placeholder_text=resume_cached,
|
resume_placeholder_text=resume_text,
|
||||||
)
|
)
|
||||||
self._drop_card_state(card_id)
|
self._drop_card_state(card_id)
|
||||||
self.card_id_dict.pop(message_id, None)
|
self.card_id_dict.pop(message_id, None)
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ spec:
|
|||||||
- china
|
- china
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/lark
|
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: domain
|
- name: domain
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/line
|
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||||
en: https://link.langbot.app/en/platforms/line
|
en: https://langbot.app/docs/en/usage/platforms/line
|
||||||
ja: https://link.langbot.app/ja/platforms/line
|
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use configured logout command, fallback to deriving from login command
|
# Use configured logout command, fallback to deriving from login command
|
||||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||||
|
|
||||||
# Cancel existing tasks
|
# Cancel existing tasks
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,75 @@
|
|||||||
|
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:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||||
en: https://link.langbot.app/en/platforms/officialaccount
|
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'],
|
app_id=config['appid'],
|
||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config.get('token', ''),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=enable_webhook,
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||||
config:
|
config:
|
||||||
- name: __system.outbound_ips
|
- name: __system.outbound_ips
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/satori
|
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||||
en: https://link.langbot.app/en/platforms/satori
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/satori
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/slack
|
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||||
en: https://link.langbot.app/en/platforms/slack
|
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||||
ja: https://link.langbot.app/ja/platforms/slack
|
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/telegram
|
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||||
en: https://link.langbot.app/en/platforms/telegram
|
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||||
ja: https://link.langbot.app/ja/platforms/telegram
|
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||||
en: https://link.langbot.app/en/platforms/wechatpad
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||||
if content['type'] == 'image':
|
if content['type'] == 'image':
|
||||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
await self.bot.send_image(user_id, agent_id, content['media_id'])
|
||||||
if content['type'] == 'voice':
|
if content['type'] == 'voice':
|
||||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
await self.bot.send_voice(user_id, agent_id, content['media_id'])
|
||||||
if content['type'] == 'file':
|
if content['type'] == 'file':
|
||||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
await self.bot.send_file(user_id, agent_id, content['media_id'])
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecom
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||||
en: https://link.langbot.app/en/platforms/wecom
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||||
ja: https://link.langbot.app/ja/platforms/wecom
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||||
en: https://link.langbot.app/en/platforms/wecomcs
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1913,9 +1913,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
|
||||||
runtime_handler = self._runtime_handler()
|
runtime_handler = self._runtime_handler()
|
||||||
binding = await self._target_binding(author, plugin_name)
|
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
|
||||||
with runtime_handler.installation_scope(binding):
|
with runtime_handler.installation_scope(binding):
|
||||||
return await runtime_handler.get_plugin_info(author, plugin_name)
|
return await runtime_handler.get_plugin_info(author, plugin_name)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""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
|
||||||
@@ -18,7 +18,7 @@ from ...discover import engine
|
|||||||
from ...entity.errors import provider as provider_errors
|
from ...entity.errors import provider as provider_errors
|
||||||
from ...entity.persistence import model as persistence_model
|
from ...entity.persistence import model as persistence_model
|
||||||
from ...workspace.entities import WorkspaceExecutionBinding
|
from ...workspace.entities import WorkspaceExecutionBinding
|
||||||
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
|
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
|
||||||
from . import requester, token
|
from . import requester, token
|
||||||
|
|
||||||
|
|
||||||
@@ -638,10 +638,32 @@ class ModelManager:
|
|||||||
) -> requester.RuntimeLLMModel:
|
) -> requester.RuntimeLLMModel:
|
||||||
execution_context = await self.resolve_execution_context(context)
|
execution_context = await self.resolve_execution_context(context)
|
||||||
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
||||||
runtime_provider = await self._build_provider(
|
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
|
||||||
execution_context,
|
inline_codex = provider_info.get('requester') == 'openai-codex'
|
||||||
persistence_model.ModelProvider(**provider_info),
|
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)
|
||||||
model_entity = persistence_model.LLMModel(
|
model_entity = persistence_model.LLMModel(
|
||||||
workspace_uuid=execution_context.workspace_uuid,
|
workspace_uuid=execution_context.workspace_uuid,
|
||||||
uuid=model_info.get('uuid', ''),
|
uuid=model_info.get('uuid', ''),
|
||||||
@@ -723,6 +745,10 @@ class ModelManager:
|
|||||||
'requester_name': provider_entity.requester,
|
'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:
|
if litellm_provider:
|
||||||
from .requesters import litellmchat
|
from .requesters import litellmchat
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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']
|
levels = ['provider_default', 'disabled', 'enabled']
|
||||||
elif family == 'doubao':
|
elif family == 'doubao':
|
||||||
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
elif family == 'ollama':
|
elif family in ('ollama', 'ollama_chat'):
|
||||||
levels = ['provider_default']
|
levels = ['provider_default']
|
||||||
levels.append('disabled')
|
levels.append('disabled')
|
||||||
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||||
@@ -1345,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
extra_args: dict[str, typing.Any] = {},
|
extra_args: dict[str, typing.Any] = {},
|
||||||
) -> tuple[list[list[float]], dict]:
|
) -> tuple[list[list[float]], dict]:
|
||||||
"""Invoke embedding and return vectors with usage info."""
|
"""Invoke embedding and return vectors with usage info."""
|
||||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
# 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)
|
||||||
api_key = model.provider.token_mgr.get_token()
|
api_key = model.provider.token_mgr.get_token()
|
||||||
|
|
||||||
args = {
|
args = {
|
||||||
@@ -1541,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(models_url, headers=headers)
|
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()
|
response.raise_for_status()
|
||||||
payload = await httpclient.parse_json_response(response)
|
payload = await httpclient.parse_json_response(response)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ metadata:
|
|||||||
zh_Hans: Ollama
|
zh_Hans: Ollama
|
||||||
icon: ollama.svg
|
icon: ollama.svg
|
||||||
spec:
|
spec:
|
||||||
litellm_provider: ollama
|
litellm_provider: ollama_chat
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class LangflowAPIRunner(runner.RequestRunner):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 如果配置中有tweaks,则添加到负载中
|
# 如果配置中有tweaks,则添加到负载中
|
||||||
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks'))
|
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks') or '{}')
|
||||||
if tweaks:
|
if tweaks:
|
||||||
payload['tweaks'] = tweaks
|
payload['tweaks'] = tweaks
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
# 获取输出键名,默认为response
|
# 获取输出键名,默认为response
|
||||||
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', '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
|
# 获取认证类型,默认为none
|
||||||
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
|
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
|
||||||
@@ -262,7 +265,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
|||||||
async with session.post(
|
async with session.post(
|
||||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if self.response_handling == 'ignore':
|
||||||
|
status_ok = 200 <= response.status < 300
|
||||||
|
else:
|
||||||
|
status_ok = response.status == 200
|
||||||
|
if not status_ok:
|
||||||
error_text = (
|
error_text = (
|
||||||
await httpclient.read_limited(
|
await httpclient.read_limited(
|
||||||
response,
|
response,
|
||||||
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
|||||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||||
raise Exception(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):
|
async for chunk in self._process_response(response):
|
||||||
if is_stream:
|
if is_stream:
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -206,6 +207,13 @@ class MCPSessionStatus(enum.Enum):
|
|||||||
ERROR = 'error'
|
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):
|
class _TransportReconnect(Exception):
|
||||||
"""Internal signal: the Box stdio WS transport dropped but the managed
|
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||||
process is still alive. Triggers a lightweight transport reconnect that
|
process is still alive. Triggers a lightweight transport reconnect that
|
||||||
@@ -265,6 +273,7 @@ class RuntimeMCPSession:
|
|||||||
_ready_event: asyncio.Event
|
_ready_event: asyncio.Event
|
||||||
|
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
|
_public_error_code: str = 'runtime_error'
|
||||||
|
|
||||||
error_phase: MCPSessionErrorPhase | None = None
|
error_phase: MCPSessionErrorPhase | None = None
|
||||||
|
|
||||||
@@ -510,6 +519,13 @@ class RuntimeMCPSession:
|
|||||||
await self._init_streamable_http_server()
|
await self._init_streamable_http_server()
|
||||||
return
|
return
|
||||||
except Exception as e:
|
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):
|
if not self._should_fallback_to_sse(e):
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
||||||
@@ -630,6 +646,7 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = str(e)
|
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()}')
|
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
|
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
|
||||||
# handle retries first. It will set the event when all retries
|
# handle retries first. It will set the event when all retries
|
||||||
@@ -752,6 +769,11 @@ class RuntimeMCPSession:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._shutdown_event.is_set():
|
if self._shutdown_event.is_set():
|
||||||
return # Shutdown requested, don't retry
|
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:
|
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
if box_service is not None and getattr(box_service, 'enabled', True):
|
if box_service is not None and getattr(box_service, 'enabled', True):
|
||||||
@@ -832,6 +854,39 @@ class RuntimeMCPSession:
|
|||||||
else:
|
else:
|
||||||
yield exc
|
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
|
@staticmethod
|
||||||
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
||||||
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
||||||
@@ -1374,7 +1429,7 @@ class RuntimeMCPSession:
|
|||||||
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
||||||
# logs; resource-list responses expose only a stable status.
|
# logs; resource-list responses expose only a stable status.
|
||||||
'error_message': 'MCP runtime failed' if self.error_message else None,
|
'error_message': 'MCP runtime failed' if self.error_message else None,
|
||||||
'error_code': 'runtime_error' if self.error_message else None,
|
'error_code': self._public_error_code if self.error_message else None,
|
||||||
'error_phase': self.error_phase.value if self.error_phase else None,
|
'error_phase': self.error_phase.value if self.error_phase else None,
|
||||||
'retry_count': self.retry_count,
|
'retry_count': self.retry_count,
|
||||||
'tool_count': len(self.get_tools()),
|
'tool_count': len(self.get_tools()),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum):
|
|||||||
MCP_INIT = 'mcp_init'
|
MCP_INIT = 'mcp_init'
|
||||||
RUNTIME = 'runtime'
|
RUNTIME = 'runtime'
|
||||||
TOOL_CALL = 'tool_call'
|
TOOL_CALL = 'tool_call'
|
||||||
|
OAUTH_REQUIRED = 'oauth_required'
|
||||||
# Stdio MCP refused because Box is disabled in config or currently
|
# Stdio MCP refused because Box is disabled in config or currently
|
||||||
# unavailable. Not transient — retries would be pointless. The frontend
|
# unavailable. Not transient — retries would be pointless. The frontend
|
||||||
# uses this phase to render a localized actionable message instead of
|
# uses this phase to render a localized actionable message instead of
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class VersionManager:
|
|||||||
try:
|
try:
|
||||||
if await self.is_new_version_available():
|
if await self.is_new_version_available():
|
||||||
return (
|
return (
|
||||||
'New version available. Update guide: https://link.langbot.app/en/docs/update',
|
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 4px 20px;">
|
<td style="padding:0 4px 20px;">
|
||||||
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
<img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
"header-name": "",
|
"header-name": "",
|
||||||
"header-value": "",
|
"header-value": "",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"output-key": "response"
|
"output-key": "response",
|
||||||
|
"response-handling": "reply"
|
||||||
},
|
},
|
||||||
"langflow-api": {
|
"langflow-api": {
|
||||||
"base-url": "http://localhost:7860",
|
"base-url": "http://localhost:7860",
|
||||||
|
|||||||
@@ -642,9 +642,10 @@
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (
|
if (
|
||||||
prevContent === content ||
|
prevContent &&
|
||||||
prevContent.indexOf(content) >= 0 ||
|
(prevContent === content ||
|
||||||
content.indexOf(prevContent) >= 0
|
prevContent.indexOf(content) >= 0 ||
|
||||||
|
content.indexOf(prevContent) >= 0)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -475,6 +475,25 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: 'response'
|
default: 'response'
|
||||||
|
- name: response-handling
|
||||||
|
label:
|
||||||
|
en_US: Webhook Response Handling
|
||||||
|
zh_Hans: Webhook 响应处理方式
|
||||||
|
description:
|
||||||
|
en_US: Choose whether LangBot forwards the n8n webhook response to the chat user. Ignore mode requires the n8n Webhook node to use Respond Immediately.
|
||||||
|
zh_Hans: 选择是否将 n8n Webhook 响应转发给聊天用户。忽略模式要求 n8n Webhook 节点使用“立即响应”。
|
||||||
|
type: select
|
||||||
|
required: false
|
||||||
|
default: 'reply'
|
||||||
|
options:
|
||||||
|
- name: reply
|
||||||
|
label:
|
||||||
|
en_US: Forward as chat reply
|
||||||
|
zh_Hans: 转发为聊天回复
|
||||||
|
- name: ignore
|
||||||
|
label:
|
||||||
|
en_US: Ignore response body (asynchronous workflow)
|
||||||
|
zh_Hans: 忽略响应正文(异步工作流)
|
||||||
- name: coze-api
|
- name: coze-api
|
||||||
label:
|
label:
|
||||||
en_US: coze API
|
en_US: coze API
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Exercise Codex provider wiring through a real LangBot process.
|
||||||
|
|
||||||
|
The default run does not contact OpenAI. Set LANGBOT_TEST_CODEX_DEVICE_AUTH=1
|
||||||
|
to also exercise live device start/pending/cancel, without account sign-in.
|
||||||
|
OAuth exchange and inference behavior are covered by deterministic tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.e2e
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_provider_disconnected_journey(e2e_client):
|
||||||
|
credentials = {'user': 'codex-e2e@example.com', 'password': 'codex-local-test-password'}
|
||||||
|
initialized = e2e_client.post('/api/v1/user/init', json=credentials)
|
||||||
|
assert initialized.status_code == 200, initialized.text
|
||||||
|
authenticated = e2e_client.post('/api/v1/user/auth', json=credentials)
|
||||||
|
assert authenticated.status_code == 200, authenticated.text
|
||||||
|
headers = {'Authorization': f'Bearer {authenticated.json()["data"]["token"]}'}
|
||||||
|
bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers)
|
||||||
|
assert bootstrap.status_code == 200, bootstrap.text
|
||||||
|
headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid']
|
||||||
|
|
||||||
|
requesters = e2e_client.get('/api/v1/provider/requesters?type=llm', headers=headers)
|
||||||
|
assert requesters.status_code == 200, requesters.text
|
||||||
|
codex = next(item for item in requesters.json()['data']['requesters'] if item['name'] == 'openai-codex')
|
||||||
|
assert codex['spec']['support_type'] == ['llm']
|
||||||
|
icon = e2e_client.get('/api/v1/provider/requesters/openai-codex/icon')
|
||||||
|
assert icon.status_code == 200
|
||||||
|
assert 'image/' in icon.headers['content-type']
|
||||||
|
|
||||||
|
base = '/api/v1/provider/providers'
|
||||||
|
created = e2e_client.post(
|
||||||
|
base,
|
||||||
|
headers=headers,
|
||||||
|
json={'name': 'Codex E2E', 'requester': 'openai-codex', 'base_url': '', 'api_keys': []},
|
||||||
|
)
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
provider_path = f'{base}/{created.json()["data"]["uuid"]}'
|
||||||
|
try:
|
||||||
|
provider = e2e_client.get(provider_path, headers=headers)
|
||||||
|
assert provider.status_code == 200, provider.text
|
||||||
|
data = provider.json()['data']['provider']
|
||||||
|
assert data['requester'] == 'openai-codex'
|
||||||
|
assert data['api_keys'] == []
|
||||||
|
assert data['base_url'] == 'https://chatgpt.com/backend-api/codex'
|
||||||
|
assert not {'access_token', 'refresh_token', 'id_token'} & data.keys()
|
||||||
|
|
||||||
|
status = e2e_client.get(f'{provider_path}/codex/status', headers=headers)
|
||||||
|
assert status.status_code == 200, status.text
|
||||||
|
assert status.json()['data']['connected'] is False
|
||||||
|
assert status.json()['data']['status'] == 'disconnected'
|
||||||
|
|
||||||
|
anonymous = e2e_client.post(f'{provider_path}/codex/device', json={})
|
||||||
|
assert anonymous.status_code == 401
|
||||||
|
invalid = e2e_client.put(provider_path, headers=headers, json={'base_url': 'https://example.com'})
|
||||||
|
assert invalid.status_code == 400, invalid.text
|
||||||
|
invalid_key = e2e_client.put(provider_path, headers=headers, json={'api_keys': ['not-a-codex-key']})
|
||||||
|
assert invalid_key.status_code == 400, invalid_key.text
|
||||||
|
|
||||||
|
scanned = e2e_client.get(f'{provider_path}/scan-models?type=llm', headers=headers)
|
||||||
|
assert scanned.status_code == 400, scanned.text
|
||||||
|
assert 'sign in' in scanned.json()['msg'].lower()
|
||||||
|
|
||||||
|
renamed = e2e_client.put(provider_path, headers=headers, json={'name': 'Codex renamed'})
|
||||||
|
assert renamed.status_code == 200, renamed.text
|
||||||
|
reread = e2e_client.get(provider_path, headers=headers)
|
||||||
|
assert reread.json()['data']['provider']['name'] == 'Codex renamed'
|
||||||
|
disconnected = e2e_client.delete(f'{provider_path}/codex/auth', headers=headers)
|
||||||
|
assert disconnected.status_code == 200, disconnected.text
|
||||||
|
|
||||||
|
# Opt-in smoke contacts real OpenAI device endpoints, but never completes
|
||||||
|
# account sign-in or prints the one-time code/device credentials.
|
||||||
|
if os.environ.get('LANGBOT_TEST_CODEX_DEVICE_AUTH') == '1':
|
||||||
|
started = e2e_client.post(f'{provider_path}/codex/device', headers=headers, json={})
|
||||||
|
assert started.status_code == 200, started.json().get('msg', 'Device start failed')
|
||||||
|
attempt = started.json()['data']
|
||||||
|
assert attempt['verification_uri'] == 'https://auth.openai.com/codex/device'
|
||||||
|
assert isinstance(attempt['user_code'], str) and attempt['user_code']
|
||||||
|
assert 0 < attempt['expires_at'] - time.time() <= 900
|
||||||
|
assert not {'access_token', 'refresh_token', 'device_auth_id'} & attempt.keys()
|
||||||
|
time.sleep(attempt['interval'])
|
||||||
|
pending = e2e_client.post(
|
||||||
|
f'{provider_path}/codex/device/poll',
|
||||||
|
headers=headers,
|
||||||
|
json={'authorization_id': attempt['authorization_id']},
|
||||||
|
)
|
||||||
|
assert pending.status_code == 200
|
||||||
|
assert pending.json()['data']['status'] == 'pending'
|
||||||
|
canceled = e2e_client.delete(f'{provider_path}/codex/device/{attempt["authorization_id"]}', headers=headers)
|
||||||
|
assert canceled.status_code == 200
|
||||||
|
expired = e2e_client.post(
|
||||||
|
f'{provider_path}/codex/device/poll',
|
||||||
|
headers=headers,
|
||||||
|
json={'authorization_id': attempt['authorization_id']},
|
||||||
|
)
|
||||||
|
assert expired.json()['data']['status'] == 'expired'
|
||||||
|
finally:
|
||||||
|
deleted = e2e_client.delete(provider_path, headers=headers)
|
||||||
|
assert deleted.status_code == 200, deleted.text
|
||||||
|
assert e2e_client.get(provider_path, headers=headers).status_code == 404
|
||||||
@@ -69,7 +69,7 @@ class LangBotProcess:
|
|||||||
# Use coverage.py to collect coverage data
|
# Use coverage.py to collect coverage data
|
||||||
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
||||||
self._coverage_file = self.work_dir / '.coverage.e2e'
|
self._coverage_file = self.work_dir / '.coverage.e2e'
|
||||||
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
|
env['COVERAGE_PROCESS_START'] = str(self.work_dir / '.coveragerc')
|
||||||
env['COVERAGE_FILE'] = str(self._coverage_file)
|
env['COVERAGE_FILE'] = str(self._coverage_file)
|
||||||
|
|
||||||
# Create .coveragerc for subprocess
|
# Create .coveragerc for subprocess
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||||
|
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.get(
|
||||||
|
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
|
||||||
|
assert kwargs['bot_ids'] == ['bot-1']
|
||||||
|
assert kwargs['user_query'] == 'alice'
|
||||||
|
assert kwargs['limit'] == 20
|
||||||
|
assert kwargs['offset'] == 40
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
class TestMonitoringErrorsEndpoint:
|
class TestMonitoringErrorsEndpoint:
|
||||||
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
|
|||||||
"""Tests for detail endpoints."""
|
"""Tests for detail endpoints."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_session_analysis(self, quart_test_client):
|
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||||
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
||||||
response = await quart_test_client.get(
|
response = await quart_test_client.get(
|
||||||
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
|
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||||
|
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||||
|
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
|
||||||
|
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
|
||||||
|
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_message_details(self, quart_test_client):
|
async def test_get_message_details(self, quart_test_client):
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||||
|
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||||
|
from langbot.pkg.api.http.service.user import UserService
|
||||||
|
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||||
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
from langbot.pkg.utils import constants
|
||||||
|
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
|
||||||
|
from langbot.pkg.workspace.service import WorkspaceService
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch):
|
||||||
|
"""Exercise generation, reset, and old/new password login without mocked user services."""
|
||||||
|
monkeypatch.setattr(constants, 'instance_id', 'recovery-journey')
|
||||||
|
monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0})
|
||||||
|
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||||
|
application = SimpleNamespace(
|
||||||
|
logger=logging.getLogger('recovery-password-journey'),
|
||||||
|
instance_config=SimpleNamespace(
|
||||||
|
data={
|
||||||
|
'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}},
|
||||||
|
'system': {
|
||||||
|
'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600},
|
||||||
|
'recovery_key': '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dump_config=AsyncMock(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await GenKeysStage().run(application)
|
||||||
|
key = application.instance_config.data['system']['recovery_key']
|
||||||
|
assert len(key) == 8
|
||||||
|
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||||
|
persistence = PersistenceManager(application)
|
||||||
|
application.persistence_mgr = persistence
|
||||||
|
try:
|
||||||
|
await persistence.initialize()
|
||||||
|
application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey')
|
||||||
|
application.workspace_collaboration_service = WorkspaceCollaborationService(
|
||||||
|
application, application.workspace_service
|
||||||
|
)
|
||||||
|
application.user_service = UserService(application)
|
||||||
|
quart_app = Quart(__name__)
|
||||||
|
await UserRouterGroup(application, quart_app).initialize()
|
||||||
|
client = quart_app.test_client()
|
||||||
|
|
||||||
|
initial = await client.post(
|
||||||
|
'/api/v1/user/init', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||||
|
)
|
||||||
|
assert initial.status_code == 200
|
||||||
|
assert (await initial.get_json())['code'] == 0
|
||||||
|
|
||||||
|
payload = {'user': 'owner@example.com', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'}
|
||||||
|
wrong = await client.post('/api/v1/user/reset-password', json=payload)
|
||||||
|
assert wrong.status_code == 403
|
||||||
|
unchanged = await client.post(
|
||||||
|
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||||
|
)
|
||||||
|
assert (await unchanged.get_json())['code'] == 0
|
||||||
|
|
||||||
|
reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key})
|
||||||
|
assert reset.status_code == 200
|
||||||
|
assert (await reset.get_json())['code'] == 0
|
||||||
|
old_login = await client.post(
|
||||||
|
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||||
|
)
|
||||||
|
assert (await old_login.get_json())['code'] != 0
|
||||||
|
new_login = await client.post(
|
||||||
|
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'RecoveredPass1!'}
|
||||||
|
)
|
||||||
|
new_data = await new_login.get_json()
|
||||||
|
assert new_data['code'] == 0
|
||||||
|
assert new_data['data']['token']
|
||||||
|
finally:
|
||||||
|
await persistence.get_db_engine().dispose()
|
||||||
@@ -27,7 +27,8 @@ async def space_oauth_api():
|
|||||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||||
)
|
)
|
||||||
application = Mock()
|
application = Mock()
|
||||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||||
|
application.directory_projection_service = None
|
||||||
application.persistence_mgr = None
|
application.persistence_mgr = None
|
||||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||||
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space/authorize-url',
|
||||||
|
query_string={
|
||||||
|
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||||
|
'cloud_entry': '1',
|
||||||
|
},
|
||||||
|
headers={'Origin': 'http://localhost'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||||
|
assert authorize_url.startswith('https://space.example/authorize?state=')
|
||||||
|
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -249,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
|||||||
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|
||||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
|
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
json={
|
||||||
|
'code': 'v4_oauth-code',
|
||||||
|
'state': 'opaque-login-state',
|
||||||
|
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (await missing.get_json())['code'] == 1
|
assert (await missing.get_json())['code'] == 1
|
||||||
@@ -260,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
|||||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||||
'oauth-code',
|
'v4_oauth-code',
|
||||||
[WORKSPACE_UUID],
|
[WORKSPACE_UUID],
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||||
|
redirect_uri='https://oss.example/auth/space/callback',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'code contract' in payload['msg']
|
||||||
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
calls: list[str] = []
|
||||||
|
application.directory_projection_service = SimpleNamespace(
|
||||||
|
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
|
||||||
|
)
|
||||||
|
application.space_service.exchange_oauth_code.return_value = {
|
||||||
|
'access_token': 'space-access-token',
|
||||||
|
'refresh_token': 'space-refresh-token',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'cloud_workspace_uuid': WORKSPACE_UUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
|
||||||
|
|
||||||
|
async def authenticate(*_args):
|
||||||
|
calls.append('authenticate')
|
||||||
|
return 'space-login-token', authenticated_account
|
||||||
|
|
||||||
|
application.user_service.authenticate_space_user.side_effect = authenticate
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||||
|
assert calls == ['reconcile', 'authenticate']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Cloud Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||||
|
launch_workspace_uuid=WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||||
|
launch_workspace_uuid=WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
application.space_service.exchange_oauth_code.return_value = {
|
||||||
|
'access_token': 'space-access-token',
|
||||||
|
'refresh_token': 'space-refresh-token',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'cloud_workspace_uuid': 'workspace-from-another-flow',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -276,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
|||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -375,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
|||||||
|
|
||||||
rejected = await client.post(
|
rejected = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
|
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||||
)
|
)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
|
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert rejected.status_code == 401
|
assert rejected.status_code == 401
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
||||||
application.user_service.verify_jwt_token.assert_not_awaited()
|
application.user_service.verify_jwt_token.assert_not_awaited()
|
||||||
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
|
application.user_service.bind_space_account.assert_awaited_once_with(
|
||||||
|
'owner@example.com',
|
||||||
|
'v4_oauth-code',
|
||||||
|
redirect_uri='http://localhost/auth/space/callback?mode=bind',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -394,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
application.user_service.consume_space_oauth_state.reset_mock()
|
application.user_service.consume_space_oauth_state.reset_mock()
|
||||||
application.space_service.exchange_oauth_code.reset_mock()
|
application.space_service.exchange_oauth_code.reset_mock()
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
@@ -414,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
)
|
)
|
||||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
projected_account = SimpleNamespace(
|
||||||
|
uuid='account-a',
|
||||||
|
user='owner@example.com',
|
||||||
|
account_type='space',
|
||||||
|
status='active',
|
||||||
|
)
|
||||||
|
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={
|
||||||
|
'workspace_uuid': WORKSPACE_UUID,
|
||||||
|
'launch_assertion': 'signed-launch-token',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -22,6 +23,7 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService
|
|||||||
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
|
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||||
|
from langbot.pkg.entity.persistence import apikey
|
||||||
from langbot.pkg.entity.persistence.user import User
|
from langbot.pkg.entity.persistence.user import User
|
||||||
from langbot.pkg.entity.persistence.workspace import (
|
from langbot.pkg.entity.persistence.workspace import (
|
||||||
Workspace,
|
Workspace,
|
||||||
@@ -440,6 +442,264 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac
|
|||||||
assert (await forbidden.get_json())['code'] == 'permission_denied'
|
assert (await forbidden.get_json())['code'] == 'permission_denied'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_key_context_returns_bound_identity_without_workspace_permission(workspace_api):
|
||||||
|
application, client, _, owner_token = workspace_api
|
||||||
|
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
||||||
|
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
|
||||||
|
|
||||||
|
create_response = await client.post(
|
||||||
|
'/api/v1/apikeys',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
json={'name': 'Context probe', 'scopes': []},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
created = (await create_response.get_json())['data']['key']
|
||||||
|
|
||||||
|
missing_auth = await client.get('/api/v1/system/context')
|
||||||
|
assert missing_auth.status_code == 401
|
||||||
|
|
||||||
|
invalid_auth = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'X-API-Key': 'lbk_invalid'},
|
||||||
|
)
|
||||||
|
assert invalid_auth.status_code == 401
|
||||||
|
|
||||||
|
invalid_capabilities = await client.get(
|
||||||
|
'/api/v1/system/capabilities',
|
||||||
|
headers={'X-API-Key': 'lbk_invalid'},
|
||||||
|
)
|
||||||
|
assert invalid_capabilities.status_code == 401
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': created['key'],
|
||||||
|
'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data'] == {
|
||||||
|
'instance_uuid': application.workspace_service.instance_uuid,
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'api_key_id': created['uuid'],
|
||||||
|
'permissions': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities_response = await client.get(
|
||||||
|
'/api/v1/system/capabilities',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': created['key'],
|
||||||
|
'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert capabilities_response.status_code == 200
|
||||||
|
capabilities = (await capabilities_response.get_json())['data']
|
||||||
|
assert capabilities['schema_version'] == 1
|
||||||
|
assert sorted(capabilities['operations']) == sorted(
|
||||||
|
[
|
||||||
|
'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',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert all(item == {'supported': True} for item in capabilities['operations'].values())
|
||||||
|
assert created['key'] not in await capabilities_response.get_data(as_text=True)
|
||||||
|
|
||||||
|
bearer_response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'Authorization': f'Bearer {created["key"]}'},
|
||||||
|
)
|
||||||
|
assert bearer_response.status_code == 200
|
||||||
|
assert (await bearer_response.get_json())['data']['api_key_id'] == created['uuid']
|
||||||
|
|
||||||
|
jwt_response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'Authorization': f'Bearer {owner_token}'},
|
||||||
|
)
|
||||||
|
assert jwt_response.status_code == 401
|
||||||
|
|
||||||
|
await application.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(apikey.ApiKey)
|
||||||
|
.where(apikey.ApiKey.uuid == created['uuid'])
|
||||||
|
.values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(seconds=1))
|
||||||
|
)
|
||||||
|
expired_capabilities = await client.get(
|
||||||
|
'/api/v1/system/capabilities',
|
||||||
|
headers={'X-API-Key': created['key']},
|
||||||
|
)
|
||||||
|
assert expired_capabilities.status_code == 401
|
||||||
|
|
||||||
|
revoke_response = await client.delete(
|
||||||
|
f'/api/v1/apikeys/{created["id"]}',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
)
|
||||||
|
assert revoke_response.status_code == 200
|
||||||
|
|
||||||
|
revoked_response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'X-API-Key': created['key']},
|
||||||
|
)
|
||||||
|
assert revoked_response.status_code == 401
|
||||||
|
revoked_capabilities = await client.get(
|
||||||
|
'/api/v1/system/capabilities',
|
||||||
|
headers={'X-API-Key': created['key']},
|
||||||
|
)
|
||||||
|
assert revoked_capabilities.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_key_can_query_tasks_with_public_contract_and_resource_permission(workspace_api):
|
||||||
|
application, client, _, owner_token = workspace_api
|
||||||
|
task_query = {}
|
||||||
|
task_lookup = {}
|
||||||
|
fake_task = SimpleNamespace(
|
||||||
|
to_public_dict=lambda: {'id': 7, 'status': 'running', 'error': None, 'result': None},
|
||||||
|
to_dict=lambda: {'id': 7, 'runtime': {'state': 'PENDING'}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_tasks_dict(*args, **kwargs):
|
||||||
|
task_query.update(kwargs)
|
||||||
|
if kwargs.get('public'):
|
||||||
|
return {'tasks': []}
|
||||||
|
return {'tasks': [], 'id_index': 1}
|
||||||
|
|
||||||
|
def get_task_by_id(*args, **kwargs):
|
||||||
|
task_lookup.update(kwargs)
|
||||||
|
return fake_task if args and args[0] == 7 else None
|
||||||
|
|
||||||
|
application.task_mgr = SimpleNamespace(
|
||||||
|
get_tasks_dict=get_tasks_dict,
|
||||||
|
get_task_by_id=get_task_by_id,
|
||||||
|
)
|
||||||
|
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
||||||
|
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
|
||||||
|
create_response = await client.post(
|
||||||
|
'/api/v1/apikeys',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
json={'name': 'Task reader', 'scopes': ['resource.view']},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
key = (await create_response.get_json())['data']['key']['key']
|
||||||
|
|
||||||
|
listing = await client.get('/api/v1/system/tasks', headers={'X-API-Key': key})
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert (await listing.get_json())['data'] == {'tasks': []}
|
||||||
|
assert task_query['instance_uuid'] == application.workspace_service.instance_uuid
|
||||||
|
assert task_query['workspace_uuid'] == workspace_uuid
|
||||||
|
assert task_query['placement_generation'] == 1
|
||||||
|
assert task_query['public'] is True
|
||||||
|
|
||||||
|
bearer_listing = await client.get('/api/v1/system/tasks', headers=_auth(owner_token, workspace_uuid))
|
||||||
|
assert bearer_listing.status_code == 200
|
||||||
|
assert (await bearer_listing.get_json())['data'] == {'tasks': [], 'id_index': 1}
|
||||||
|
|
||||||
|
public_task = await client.get('/api/v1/system/tasks/7', headers={'X-API-Key': key})
|
||||||
|
assert public_task.status_code == 200
|
||||||
|
assert (await public_task.get_json())['data'] == {
|
||||||
|
'id': 7,
|
||||||
|
'status': 'running',
|
||||||
|
'error': None,
|
||||||
|
'result': None,
|
||||||
|
}
|
||||||
|
assert task_lookup == {
|
||||||
|
'instance_uuid': application.workspace_service.instance_uuid,
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'placement_generation': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy_task = await client.get('/api/v1/system/tasks/7', headers=_auth(owner_token, workspace_uuid))
|
||||||
|
assert legacy_task.status_code == 200
|
||||||
|
assert (await legacy_task.get_json())['data'] == {'id': 7, 'runtime': {'state': 'PENDING'}}
|
||||||
|
|
||||||
|
missing = await client.get('/api/v1/system/tasks/not-an-id', headers={'X-API-Key': key})
|
||||||
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
no_permission_response = await client.post(
|
||||||
|
'/api/v1/apikeys',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
json={'name': 'Task denied', 'scopes': []},
|
||||||
|
)
|
||||||
|
assert no_permission_response.status_code == 200
|
||||||
|
no_permission_key = (await no_permission_response.get_json())['data']['key']['key']
|
||||||
|
denied = await client.get('/api/v1/system/tasks', headers={'X-API-Key': no_permission_key})
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
||||||
workspace_api,
|
workspace_api,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
"""Deterministic OAuth tests using real SQLite CAS writes, never live credentials."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.authz import Permission
|
||||||
|
from langbot.pkg.api.http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||||
|
from langbot.pkg.entity.persistence.model import CodexCredential
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
||||||
|
from langbot.pkg.provider.modelmgr.codex_auth import CodexAuth, _tokens, validate_config
|
||||||
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
def context(workspace='w', user='u', principal=PrincipalType.ACCOUNT, permitted=True):
|
||||||
|
return RequestContext(
|
||||||
|
'i',
|
||||||
|
0,
|
||||||
|
'r',
|
||||||
|
'user_token',
|
||||||
|
PrincipalContext(principal, account_uuid=user),
|
||||||
|
WorkspaceContext(
|
||||||
|
workspace, 'm', 'owner', frozenset({Permission.PROVIDER_SECRET_MANAGE} if permitted else set())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jwt(**claims):
|
||||||
|
return 'test.' + base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=') + '.test'
|
||||||
|
|
||||||
|
|
||||||
|
def token_response(**extra):
|
||||||
|
return {
|
||||||
|
'access_token': jwt(**{'https://api.openai.com/auth': {'chatgpt_account_id': 'account'}}),
|
||||||
|
'refresh_token': 'refresh-secret',
|
||||||
|
'expires_in': 3600,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def auth(tmp_path):
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "codex.db"}')
|
||||||
|
|
||||||
|
@sa.event.listens_for(engine.sync_engine, 'connect')
|
||||||
|
def foreign_keys(connection, _):
|
||||||
|
connection.execute('PRAGMA foreign_keys=ON')
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
sa.text(
|
||||||
|
'CREATE TABLE model_providers (uuid VARCHAR(255) PRIMARY KEY, workspace_uuid VARCHAR(36) NOT NULL, requester TEXT, UNIQUE(workspace_uuid, uuid))'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO model_providers VALUES ('p','w','openai-codex'), ('other','other','openai-codex'), ('api','w','openai-chat-completions')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await run_alembic_stamp(engine, '0021_merge_reasoning_config')
|
||||||
|
await run_alembic_upgrade(engine, '0022_codex_credentials')
|
||||||
|
|
||||||
|
async def execute(statement):
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
return await conn.execute(statement)
|
||||||
|
|
||||||
|
service = CodexAuth(SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=execute)))
|
||||||
|
service.engine = engine
|
||||||
|
await execute(
|
||||||
|
sa.insert(CodexCredential).values(provider_uuid='p', workspace_uuid='w', payload={}, version=0, lease_until=0)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield service
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(auth, payload):
|
||||||
|
await auth.ap.persistence_mgr.execute_async(sa.update(CodexCredential).values(payload=payload))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_upgrade_repeat_fk_cascade(auth):
|
||||||
|
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
|
||||||
|
await run_alembic_stamp(auth.engine, '0021_merge_reasoning_config')
|
||||||
|
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
|
||||||
|
with pytest.raises(sa.exc.IntegrityError):
|
||||||
|
await auth.ap.persistence_mgr.execute_async(
|
||||||
|
sa.insert(CodexCredential).values(
|
||||||
|
provider_uuid='other', workspace_uuid='w', payload={}, version=0, lease_until=0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await auth.ap.persistence_mgr.execute_async(sa.text("DELETE FROM model_providers WHERE uuid='p'"))
|
||||||
|
assert await auth._read('w', 'p') is None
|
||||||
|
from langbot.pkg.persistence.alembic_runner import run_alembic_downgrade
|
||||||
|
|
||||||
|
await run_alembic_downgrade(auth.engine, '0021_merge_reasoning_config')
|
||||||
|
async with auth.engine.connect() as conn:
|
||||||
|
assert 'codex_credentials' not in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||||
|
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
|
||||||
|
async with auth.engine.connect() as conn:
|
||||||
|
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_pacing_exchange_secrecy_and_user_binding(auth):
|
||||||
|
auth._post = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
httpx.Response(200, json={'device_auth_id': 'device-secret', 'usercode': 'CODE', 'interval': '5'}),
|
||||||
|
httpx.Response(200, json={'authorization_code': 'code-secret', 'code_verifier': 'verifier-secret'}),
|
||||||
|
httpx.Response(200, json=token_response()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
start = await auth.start(context(), 'p')
|
||||||
|
assert set(start) == {'authorization_id', 'user_code', 'interval', 'expires_at', 'verification_uri'}
|
||||||
|
assert 'device-secret' not in json.dumps(start)
|
||||||
|
attempt = start['authorization_id']
|
||||||
|
with pytest.raises(WorkspaceNotFoundError):
|
||||||
|
await auth.poll(context(user='attacker'), 'p', attempt)
|
||||||
|
assert (await auth.poll(context(), 'p', attempt))['status'] == 'pending'
|
||||||
|
assert auth._post.await_count == 1
|
||||||
|
row = await auth._read('w', 'p')
|
||||||
|
row['payload']['pending']['next_poll_at'] = 0
|
||||||
|
await seed(auth, row['payload'])
|
||||||
|
assert await auth.poll(context(), 'p', attempt) == {'status': 'connected'}
|
||||||
|
assert await auth.poll(context(), 'p', attempt) == {'status': 'connected'}
|
||||||
|
exchange = auth._post.call_args.kwargs['data']
|
||||||
|
assert exchange['grant_type'] == 'authorization_code'
|
||||||
|
assert exchange['redirect_uri'] == 'https://auth.openai.com/deviceauth/callback'
|
||||||
|
assert exchange['code_verifier'] == 'verifier-secret'
|
||||||
|
status = await auth.status(context(), 'p')
|
||||||
|
assert set(status) == {'status', 'connected', 'expires_at'}
|
||||||
|
assert 'secret' not in json.dumps(status)
|
||||||
|
await auth.disconnect(context(), 'p')
|
||||||
|
assert (await auth._read('w', 'p'))['payload'] == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'ctx,provider,error',
|
||||||
|
[
|
||||||
|
(context('other'), 'p', WorkspaceNotFoundError),
|
||||||
|
(context(principal=PrincipalType.API_KEY), 'p', ValueError),
|
||||||
|
(context(permitted=False), 'p', ValueError),
|
||||||
|
(context(), 'api', ValueError),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_auth_tenant_principal_permission_guards(auth, ctx, provider, error):
|
||||||
|
auth._post = AsyncMock()
|
||||||
|
with pytest.raises(error):
|
||||||
|
await auth.start(ctx, provider)
|
||||||
|
auth._post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_cross_instance_single_flight_and_rotation(auth):
|
||||||
|
old = _tokens(token_response())
|
||||||
|
old['expires_at'] = 0
|
||||||
|
await seed(auth, {'tokens': old})
|
||||||
|
entered, release = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
async def refresh(*args, **kwargs):
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
return httpx.Response(200, json=token_response(refresh_token='rotated-secret'))
|
||||||
|
|
||||||
|
auth._post = AsyncMock(side_effect=refresh)
|
||||||
|
other = CodexAuth(auth.ap)
|
||||||
|
other._post = auth._post
|
||||||
|
first = asyncio.create_task(auth.access('w', 'p'))
|
||||||
|
await entered.wait()
|
||||||
|
second = asyncio.create_task(other.access('w', 'p'))
|
||||||
|
release.set()
|
||||||
|
a, b = await asyncio.gather(first, second)
|
||||||
|
assert a == b
|
||||||
|
assert a['refresh_token'] == 'rotated-secret'
|
||||||
|
assert auth._post.await_count == 1
|
||||||
|
assert (await auth._read('w', 'p'))['payload']['tokens'] == a
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'status,error,invalid',
|
||||||
|
[
|
||||||
|
(400, 'invalid_grant', True),
|
||||||
|
(401, 'refresh_token_reused', True),
|
||||||
|
(429, 'limited', False),
|
||||||
|
(500, 'secret-upstream-body', False),
|
||||||
|
(403, 'permission_denied', False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_refresh_errors_are_safe_and_transient_preserves_tokens(auth, status, error, invalid):
|
||||||
|
old = _tokens(token_response())
|
||||||
|
old['expires_at'] = 0
|
||||||
|
await seed(auth, {'tokens': old})
|
||||||
|
auth._post = AsyncMock(
|
||||||
|
return_value=httpx.Response(status, json={'error': error, 'access_token': 'secret-upstream-body'})
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError) as caught:
|
||||||
|
await auth.access('w', 'p')
|
||||||
|
assert 'secret' not in str(caught.value)
|
||||||
|
payload = (await auth._read('w', 'p'))['payload']
|
||||||
|
assert bool(payload.get('invalid')) == invalid
|
||||||
|
assert ('tokens' not in payload) if invalid else payload['tokens'] == old
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('cancel', [False, True])
|
||||||
|
async def test_disconnect_or_cancel_fences_inflight_exchange(auth, cancel):
|
||||||
|
old = _tokens(token_response())
|
||||||
|
await seed(
|
||||||
|
auth,
|
||||||
|
{
|
||||||
|
'tokens': old,
|
||||||
|
'pending': {
|
||||||
|
'authorization_id': 'attempt',
|
||||||
|
'account_uuid': 'u',
|
||||||
|
'expires_at': time.time() + 100,
|
||||||
|
'next_poll_at': 0,
|
||||||
|
'interval': 5,
|
||||||
|
'device_auth_id': 'device',
|
||||||
|
'user_code': 'code',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entered, release = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
async def post(path, **kwargs):
|
||||||
|
if path.endswith('/token') and path != '/oauth/token':
|
||||||
|
return httpx.Response(200, json={'authorization_code': 'code', 'code_verifier': 'verifier'})
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
return httpx.Response(200, json=token_response())
|
||||||
|
|
||||||
|
auth._post = post
|
||||||
|
task = asyncio.create_task(auth.poll(context(), 'p', 'attempt'))
|
||||||
|
await entered.wait()
|
||||||
|
if cancel:
|
||||||
|
await auth.cancel(context(), 'p', 'attempt')
|
||||||
|
else:
|
||||||
|
await auth.disconnect(context(), 'p')
|
||||||
|
release.set()
|
||||||
|
with pytest.raises(ValueError, match='cancelled or replaced'):
|
||||||
|
await task
|
||||||
|
payload = (await auth._read('w', 'p'))['payload']
|
||||||
|
assert payload == ({'tokens': old} if cancel else {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('status,interval', [(403, 5), (404, 5), (429, 10)])
|
||||||
|
async def test_device_pending_and_backoff(auth, status, interval):
|
||||||
|
await seed(
|
||||||
|
auth,
|
||||||
|
{
|
||||||
|
'pending': {
|
||||||
|
'authorization_id': 'attempt',
|
||||||
|
'account_uuid': 'u',
|
||||||
|
'expires_at': time.time() + 100,
|
||||||
|
'next_poll_at': 0,
|
||||||
|
'interval': 5,
|
||||||
|
'device_auth_id': 'device',
|
||||||
|
'user_code': 'code',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
auth._post = AsyncMock(return_value=httpx.Response(status))
|
||||||
|
assert await auth.poll(context(), 'p', 'attempt') == {'status': 'pending', 'interval': interval}
|
||||||
|
assert await auth.poll(context(), 'p', 'attempt') == {'status': 'pending', 'interval': interval}
|
||||||
|
assert auth._post.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_replacement_expiry_and_idempotent_cancel(auth):
|
||||||
|
auth._post = AsyncMock(return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE'}))
|
||||||
|
first = await auth.start(context(), 'p')
|
||||||
|
second = await auth.start(context(), 'p')
|
||||||
|
assert first['authorization_id'] != second['authorization_id']
|
||||||
|
assert await auth.poll(context(), 'p', first['authorization_id']) == {'status': 'expired'}
|
||||||
|
await auth.cancel(context(), 'p', first['authorization_id'])
|
||||||
|
payload = (await auth._read('w', 'p'))['payload']
|
||||||
|
assert payload['pending']['authorization_id'] == second['authorization_id']
|
||||||
|
payload['pending']['expires_at'] = 0
|
||||||
|
await seed(auth, payload)
|
||||||
|
assert await auth.poll(context(), 'p', second['authorization_id']) == {'status': 'expired'}
|
||||||
|
assert 'pending' not in (await auth._read('w', 'p'))['payload']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_accepts_issuer_iso_expiry(auth):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||||
|
auth._post = AsyncMock(
|
||||||
|
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires})
|
||||||
|
)
|
||||||
|
result = await auth.start(context(), 'p')
|
||||||
|
assert time.time() < result['expires_at'] < time.time() + 900
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_expires_in_fallback(auth):
|
||||||
|
auth._post = AsyncMock(
|
||||||
|
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_in': 60})
|
||||||
|
)
|
||||||
|
result = await auth.start(context(), 'p')
|
||||||
|
assert time.time() < result['expires_at'] <= time.time() + 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('cancel_reads_before_refresh', [False, True])
|
||||||
|
async def test_cancel_pending_relogin_waits_for_existing_refresh(auth, cancel_reads_before_refresh):
|
||||||
|
old = _tokens(token_response())
|
||||||
|
old['expires_at'] = 0
|
||||||
|
await seed(auth, {'tokens': old, 'pending': {'authorization_id': 'attempt', 'account_uuid': 'u'}})
|
||||||
|
entered, release, cancel_read = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
async def refresh(*args, **kwargs):
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
return httpx.Response(200, json=token_response(refresh_token='rotated-secret'))
|
||||||
|
|
||||||
|
other = CodexAuth(auth.ap)
|
||||||
|
original_read = other._read
|
||||||
|
|
||||||
|
async def read(workspace, provider):
|
||||||
|
row = await original_read(workspace, provider)
|
||||||
|
cancel_read.set()
|
||||||
|
if cancel_reads_before_refresh:
|
||||||
|
await entered.wait()
|
||||||
|
return row
|
||||||
|
|
||||||
|
other._read = read
|
||||||
|
auth._post = refresh
|
||||||
|
if cancel_reads_before_refresh:
|
||||||
|
cancelling = asyncio.create_task(other.cancel(context(), 'p', 'attempt'))
|
||||||
|
await cancel_read.wait()
|
||||||
|
refreshing = asyncio.create_task(auth.access('w', 'p'))
|
||||||
|
await entered.wait()
|
||||||
|
if not cancel_reads_before_refresh:
|
||||||
|
cancelling = asyncio.create_task(other.cancel(context(), 'p', 'attempt'))
|
||||||
|
await cancel_read.wait()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
try:
|
||||||
|
assert not cancelling.done(), 'Cancellation must not revoke the refresh lease'
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
results = await asyncio.gather(refreshing, cancelling, return_exceptions=True)
|
||||||
|
assert not any(isinstance(result, Exception) for result in results)
|
||||||
|
payload = (await auth._read('w', 'p'))['payload']
|
||||||
|
assert payload['tokens']['refresh_token'] == 'rotated-secret'
|
||||||
|
assert 'pending' not in payload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('operation', ['save', 'cancel', 'disconnect', 'acquire', 'release', 'read'])
|
||||||
|
async def test_credential_database_errors_never_expose_secrets(auth, operation):
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
markers = ['ACCESS-MARKER', 'REFRESH-MARKER', 'DEVICE-MARKER', 'VERIFIER-MARKER']
|
||||||
|
payload = {
|
||||||
|
'tokens': {'access_token': markers[0], 'refresh_token': markers[1]},
|
||||||
|
'pending': {
|
||||||
|
'authorization_id': 'attempt',
|
||||||
|
'account_uuid': 'u',
|
||||||
|
'device_auth_id': markers[2],
|
||||||
|
'code_verifier': markers[3],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await seed(auth, payload)
|
||||||
|
if operation == 'read':
|
||||||
|
auth.ap.persistence_mgr.execute_async = AsyncMock(
|
||||||
|
side_effect=sa.exc.StatementError(
|
||||||
|
'failure', 'SELECT credentials', {'payload': payload}, RuntimeError(markers[0])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
column = 'payload' if operation in ('save', 'cancel', 'disconnect') else 'lease_owner'
|
||||||
|
condition = ' WHEN NEW.lease_owner IS NULL' if operation == 'release' else ''
|
||||||
|
# Trigger errors can themselves contain secrets, even for parameter-free writes.
|
||||||
|
await auth.ap.persistence_mgr.execute_async(
|
||||||
|
sa.text(
|
||||||
|
f'CREATE TRIGGER reject_write BEFORE UPDATE OF {column} ON codex_credentials{condition} '
|
||||||
|
f"BEGIN SELECT RAISE(ABORT, '{' '.join(markers)}'); END"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match='credential storage') as caught:
|
||||||
|
if operation == 'save':
|
||||||
|
async with auth._lease('w', 'p') as owner:
|
||||||
|
await auth._save('w', 'p', owner, payload)
|
||||||
|
elif operation == 'cancel':
|
||||||
|
await auth.cancel(context(), 'p', 'attempt')
|
||||||
|
elif operation == 'disconnect':
|
||||||
|
await auth.disconnect(context(), 'p')
|
||||||
|
elif operation == 'read':
|
||||||
|
await auth._read('w', 'p')
|
||||||
|
else:
|
||||||
|
async with auth._lease('w', 'p'):
|
||||||
|
pass
|
||||||
|
rendered = ''.join(traceback.format_exception(caught.value))
|
||||||
|
assert all(marker not in rendered for marker in markers)
|
||||||
|
assert caught.value.__suppress_context__
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credential_serialization_failure_is_sanitized(auth):
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
class Secret:
|
||||||
|
def __repr__(self):
|
||||||
|
return 'SERIALIZATION-SECRET'
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='credential storage') as caught:
|
||||||
|
async with auth._lease('w', 'p') as owner:
|
||||||
|
await auth._save('w', 'p', owner, {'tokens': {'refresh_token': Secret()}})
|
||||||
|
assert 'SERIALIZATION-SECRET' not in ''.join(traceback.format_exception(caught.value))
|
||||||
|
assert caught.value.__suppress_context__
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_refresh_fallback_and_config_validation():
|
||||||
|
old = _tokens(token_response())
|
||||||
|
refreshed = _tokens({'access_token': 'opaque-access', 'expires_in': 3600}, old)
|
||||||
|
assert refreshed['refresh_token'] == old['refresh_token']
|
||||||
|
assert refreshed['connection_id'] == old['connection_id']
|
||||||
|
for expiry in [float('nan'), float('inf'), -1, 'bad']:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_tokens(token_response(expires_in=expiry))
|
||||||
|
data = {'requester': 'openai-codex'}
|
||||||
|
validate_config(data)
|
||||||
|
assert data['api_keys'] == []
|
||||||
|
for update in [{'base_url': 'https://evil.invalid'}, {'api_keys': ['secret']}]:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_config({**data, **update})
|
||||||
|
ordinary = {'requester': 'openai-chat-completions', 'api_keys': ['key'], 'base_url': 'https://custom.invalid'}
|
||||||
|
before = dict(ordinary)
|
||||||
|
validate_config(ordinary)
|
||||||
|
assert ordinary == before
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user