mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 17:47:14 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f216a0d4d | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 |
@@ -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:
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.9"
|
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.6",
|
"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]
|
||||||
|
|||||||
@@ -1349,7 +1349,8 @@
|
|||||||
"local-agent",
|
"local-agent",
|
||||||
"tools",
|
"tools",
|
||||||
"e2b",
|
"e2b",
|
||||||
"nsjail"
|
"nsjail",
|
||||||
|
"host"
|
||||||
],
|
],
|
||||||
"automation": "",
|
"automation": "",
|
||||||
"setup_automation": [],
|
"setup_automation": [],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Key settings:
|
|||||||
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
||||||
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
||||||
| `box.enabled` | Master switch for the Box sandbox runtime |
|
| `box.enabled` | Master switch for the Box sandbox runtime |
|
||||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
|
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
|
||||||
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
||||||
|
|
||||||
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||||
@@ -75,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
|||||||
with `--standalone-runtime`.
|
with `--standalone-runtime`.
|
||||||
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
||||||
`langbot_box:5410`.
|
`langbot_box:5410`.
|
||||||
|
- `box.backend: host` runs commands directly as the Box Runtime system user.
|
||||||
|
It is never auto-selected, provides no sandbox isolation, and is only for
|
||||||
|
trusted local development. A WebSocket-controlled host backend requires
|
||||||
|
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
|
||||||
|
|
||||||
## Global API key — enabling for agents/automation
|
## Global API key — enabling for agents/automation
|
||||||
|
|
||||||
@@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
|
|||||||
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
||||||
usually means the user isn't in the `docker` group →
|
usually means the user isn't in the `docker` group →
|
||||||
`sudo usermod -aG docker <user>` and restart in a new shell.
|
`sudo usermod -aG docker <user>` and restart in a new shell.
|
||||||
|
- Do not use `box.backend: host` as a production fallback. It cannot enforce
|
||||||
|
image, filesystem, network, PID, CPU, memory, or storage isolation.
|
||||||
- Box root host/container path mismatch breaks sandbox container creation.
|
- Box root host/container path mismatch breaks sandbox container creation.
|
||||||
- Don't commit a non-empty `api.global_api_key` to version control.
|
- Don't commit a non-empty `api.global_api_key` to version control.
|
||||||
|
|||||||
@@ -86,6 +86,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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ tags:
|
|||||||
- tools
|
- tools
|
||||||
- e2b
|
- e2b
|
||||||
- nsjail
|
- nsjail
|
||||||
|
- host
|
||||||
skills:
|
skills:
|
||||||
- langbot-env-setup
|
- langbot-env-setup
|
||||||
- langbot-testing
|
- langbot-testing
|
||||||
@@ -23,7 +24,7 @@ env:
|
|||||||
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
||||||
preconditions:
|
preconditions:
|
||||||
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
||||||
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
|
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
|
||||||
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
||||||
steps:
|
steps:
|
||||||
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
||||||
@@ -33,7 +34,7 @@ steps:
|
|||||||
checks:
|
checks:
|
||||||
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
||||||
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
||||||
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
|
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
|
||||||
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
||||||
- "Box status: recent_error_count is 0 after the run."
|
- "Box status: recent_error_count is 0 after the run."
|
||||||
evidence_required:
|
evidence_required:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
||||||
|
|
||||||
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
|
|||||||
- `BOX_BACKEND=e2b` when validating E2B.
|
- `BOX_BACKEND=e2b` when validating E2B.
|
||||||
- `BOX_BACKEND=nsjail` when validating nsjail.
|
- `BOX_BACKEND=nsjail` when validating nsjail.
|
||||||
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
||||||
|
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
|
||||||
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
||||||
4. Confirm Debug Chat uses a model with function-calling ability.
|
4. Confirm Debug Chat uses a model with function-calling ability.
|
||||||
5. Confirm backend logs say native sandbox tools are available.
|
5. Confirm backend logs say native sandbox tools are available.
|
||||||
@@ -71,7 +72,7 @@ Backend logs should show:
|
|||||||
- `register_skill`
|
- `register_skill`
|
||||||
- `activate`
|
- `activate`
|
||||||
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
||||||
- `backend=e2b`, `backend=nsjail`, or the expected local backend
|
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
|
||||||
|
|
||||||
After the run, verify the skill store through the UI or API:
|
After the run, verify the skill store through the UI or API:
|
||||||
|
|
||||||
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
|
|||||||
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
||||||
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
||||||
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
||||||
|
- The host backend does not honor sandbox image, network, rootfs, process, or
|
||||||
|
resource isolation. Use a disposable workspace and low-privilege account.
|
||||||
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
||||||
|
|
||||||
## Related Troubleshooting
|
## Related Troubleshooting
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
|
|||||||
date: 2026-05-18
|
date: 2026-05-18
|
||||||
symptoms:
|
symptoms:
|
||||||
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
||||||
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
|
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
|
||||||
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
||||||
patterns:
|
patterns:
|
||||||
- "Native sandbox tools ... are NOT available"
|
- "Native sandbox tools ... are NOT available"
|
||||||
@@ -19,6 +19,7 @@ fix_steps:
|
|||||||
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
||||||
- "For E2B, verify the key without printing it and confirm any required template setting."
|
- "For E2B, verify the key without printing it and confirm any required template setting."
|
||||||
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
||||||
|
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
|
||||||
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
||||||
related_cases:
|
related_cases:
|
||||||
- sandbox-skill-authoring-e2e
|
- sandbox-skill-authoring-e2e
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,6 +8,80 @@ from ... import group
|
|||||||
@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'],
|
||||||
@@ -82,7 +156,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))
|
||||||
|
|||||||
@@ -186,6 +186,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 +202,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 +224,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 +385,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 +408,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 +454,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']
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -455,7 +455,9 @@ class BoxService:
|
|||||||
|
|
||||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
if self._cloud_managed:
|
if self._cloud_managed:
|
||||||
if self._admission is None:
|
if self._admission is None:
|
||||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||||
@@ -565,7 +567,9 @@ class BoxService:
|
|||||||
skip_host_mount_validation: bool = False,
|
skip_host_mount_validation: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||||
await self._require_validated_workspace_sandbox(execution_context)
|
await self._require_validated_workspace_sandbox(execution_context)
|
||||||
@@ -2142,5 +2146,8 @@ class BoxService:
|
|||||||
if backend_name:
|
if backend_name:
|
||||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||||
else:
|
else:
|
||||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
payload['connector_error'] = (
|
||||||
|
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||||
|
'Trusted local development may explicitly select the unsafe host backend.'
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||||
|
'Trusted local development may explicitly select box.backend=host. '
|
||||||
'The LLM will not have access to code execution or file operation tools.'
|
'The LLM will not have access to code execution or file operation tools.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
else:
|
else:
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
'Skill tools (activate/register_skill) are NOT available. '
|
'Skill tools (activate/register_skill) are NOT available. '
|
||||||
'No sandbox backend (Docker/nsjail/E2B) is ready.'
|
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||||
|
'Trusted local development may explicitly select box.backend=host.'
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_sandbox_available(self) -> bool:
|
async def _check_sandbox_available(self) -> bool:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -331,7 +331,10 @@ box:
|
|||||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||||
# be listed read-only and http/sse MCP servers continue to work.
|
# be listed read-only and http/sse MCP servers continue to work.
|
||||||
enabled: true
|
enabled: true
|
||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||||
|
# isolation. It is never auto-selected and is only for trusted local
|
||||||
|
# development. Can be written via BOX__BACKEND.
|
||||||
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||||
runtime:
|
runtime:
|
||||||
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||||
# runtimes. To protect an exposed endpoint, set the same strong secret
|
# runtimes. To protect an exposed endpoint, set the same strong secret
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
assert _get_script_head() == '0021_merge_reasoning_config'
|
assert _get_script_head() == '0022_codex_credentials'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
||||||
@@ -119,7 +119,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == '0021_merge_reasoning_config'
|
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
|
|||||||
@@ -549,10 +549,12 @@ class TestPostgreSQLWorkspaceMigration:
|
|||||||
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||||
)
|
)
|
||||||
assert 'workspaces' not in tables_before_migration
|
assert 'workspaces' not in tables_before_migration
|
||||||
|
assert 'codex_credentials' not in tables_before_migration
|
||||||
|
|
||||||
await manager._initialize_managed_schema()
|
await manager._initialize_managed_schema()
|
||||||
|
|
||||||
async with postgres_engine.connect() as conn:
|
async with postgres_engine.connect() as conn:
|
||||||
|
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||||
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
||||||
workspace = (
|
workspace = (
|
||||||
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
|
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -34,7 +35,7 @@ def _manifest_payloads(backup_directory) -> list[dict]:
|
|||||||
|
|
||||||
def _assert_verified_backup(payload: dict) -> None:
|
def _assert_verified_backup(payload: dict) -> None:
|
||||||
backup_path = pathlib.Path(payload['backup_path'])
|
backup_path = pathlib.Path(payload['backup_path'])
|
||||||
with sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True) as connection:
|
with closing(sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True)) as connection:
|
||||||
assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
|
assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
|
||||||
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
||||||
|
|
||||||
|
|||||||
@@ -403,10 +403,12 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||||
)
|
)
|
||||||
assert 'workspaces' not in tables_before_migration
|
assert 'workspaces' not in tables_before_migration
|
||||||
|
assert 'codex_credentials' not in tables_before_migration
|
||||||
|
|
||||||
await manager._run_alembic_migrations()
|
await manager._run_alembic_migrations()
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
|
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||||
workspace = (
|
workspace = (
|
||||||
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
|
|||||||
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -28,15 +29,10 @@ WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
|||||||
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||||
|
|
||||||
|
|
||||||
class _PersistenceManager:
|
class _PersistenceManager(PersistenceManager):
|
||||||
def __init__(self, engine):
|
def __init__(self, engine):
|
||||||
self.engine = engine
|
super().__init__(SimpleNamespace())
|
||||||
|
self.db = SimpleNamespace(get_engine=lambda: engine)
|
||||||
async def execute_async(self, *args, **kwargs):
|
|
||||||
async with self.engine.connect() as connection:
|
|
||||||
result = await connection.execute(*args, **kwargs)
|
|
||||||
await connection.commit()
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_model(model, data, masked_columns=None):
|
def serialize_model(model, data, masked_columns=None):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import pytest
|
|||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import json
|
import json
|
||||||
|
import sqlalchemy
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.bot import BotService
|
from langbot.pkg.api.http.service.bot import BotService
|
||||||
@@ -449,10 +450,58 @@ class TestBotServiceCreateBot:
|
|||||||
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
||||||
insert_values = insert_statement.compile().params
|
insert_values = insert_statement.compile().params
|
||||||
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
|
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
|
||||||
assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
|
|
||||||
assert insert_values['use_pipeline_name'] == 'Default Pipeline'
|
|
||||||
assert bot_uuid is not None # Verify UUID was returned
|
assert bot_uuid is not None # Verify UUID was returned
|
||||||
|
|
||||||
|
async def test_create_bot_rolls_back_insert_when_load_bot_fails(self):
|
||||||
|
"""Deletes the inserted row when the adapter fails to load.
|
||||||
|
|
||||||
|
Regression: a failing adapter constructor (e.g. KeyError on a missing
|
||||||
|
optional credential key) used to leave a permanently disabled orphan
|
||||||
|
bot in the DB — the insert was already committed and the HTTP layer
|
||||||
|
surfaced a 500 without any cleanup.
|
||||||
|
"""
|
||||||
|
# Setup
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace()
|
||||||
|
ap.instance_config = SimpleNamespace()
|
||||||
|
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
|
||||||
|
ap.platform_mgr = SimpleNamespace()
|
||||||
|
ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token'))
|
||||||
|
|
||||||
|
pipeline_result = Mock()
|
||||||
|
pipeline_result.first = Mock(return_value=None)
|
||||||
|
bot_result = Mock()
|
||||||
|
bot_result.first = Mock(return_value=_create_mock_bot())
|
||||||
|
|
||||||
|
executed_statements = []
|
||||||
|
|
||||||
|
async def mock_execute(query):
|
||||||
|
executed_statements.append(query)
|
||||||
|
if len(executed_statements) <= 2:
|
||||||
|
return pipeline_result # 1: limitation bots query, 2: pipeline query
|
||||||
|
if len(executed_statements) == 3:
|
||||||
|
return Mock() # insert
|
||||||
|
return bot_result # get_bot after insert
|
||||||
|
|
||||||
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||||
|
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
|
||||||
|
|
||||||
|
service = BotService(ap)
|
||||||
|
|
||||||
|
# Execute & Verify: the adapter error propagates
|
||||||
|
with pytest.raises(KeyError, match='token'):
|
||||||
|
await service.create_bot(
|
||||||
|
WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# And the inserted row is rolled back via a DELETE on the new uuid
|
||||||
|
# (no limitation query runs because max_bots=-1)
|
||||||
|
assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete
|
||||||
|
delete_statement = executed_statements[-1]
|
||||||
|
assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete)
|
||||||
|
compiled = delete_statement.compile()
|
||||||
|
assert compiled.params['uuid_1'] is not None
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceUpdateBot:
|
class TestBotServiceUpdateBot:
|
||||||
"""Tests for update_bot method."""
|
"""Tests for update_bot method."""
|
||||||
|
|||||||
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
|||||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_search_matches_user_id_or_name_within_workspace(service):
|
||||||
|
context_a = _context(WORKSPACE_A)
|
||||||
|
context_b = _context(WORKSPACE_B)
|
||||||
|
fixtures = [
|
||||||
|
(context_a, 'session-id-match', 'customer-42', 'Alice'),
|
||||||
|
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
|
||||||
|
(context_a, 'session-no-match', 'customer-7', 'Bob'),
|
||||||
|
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||||
|
]
|
||||||
|
for context, session_id, user_id, user_name in fixtures:
|
||||||
|
await service.record_session_start(
|
||||||
|
context,
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id='same-bot',
|
||||||
|
bot_name='Same Bot',
|
||||||
|
pipeline_id='same-pipeline',
|
||||||
|
pipeline_name='Same Pipeline',
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=user_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
|
||||||
|
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
|
||||||
|
|
||||||
|
assert id_total == 1
|
||||||
|
assert [session['session_id'] for session in by_id] == ['session-id-match']
|
||||||
|
assert name_total == 2
|
||||||
|
assert {session['session_id'] for session in by_name} == {
|
||||||
|
'session-id-match',
|
||||||
|
'session-name-match',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||||
context = _context(WORKSPACE_A)
|
context = _context(WORKSPACE_A)
|
||||||
message_id = await _record_message(service, context, 'tool context')
|
message_id = await _record_message(service, context, 'tool context')
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Provider deletion uses real SQLite transactions and real runtime cache cleanup."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
import quart
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup
|
||||||
|
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||||
|
from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel
|
||||||
|
from langbot.pkg.entity.persistence.user import User
|
||||||
|
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||||
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||||
|
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||||
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
MODEL_TYPES = (LLMModel, EmbeddingModel, RerankModel)
|
||||||
|
TABLES = (*MODEL_TYPES, CodexCredential, ModelProvider)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def deletion(tmp_path):
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "cascade.db"}')
|
||||||
|
|
||||||
|
@sa.event.listens_for(engine.sync_engine, 'connect')
|
||||||
|
def enable_foreign_keys(connection, _record):
|
||||||
|
connection.execute('PRAGMA foreign_keys=ON')
|
||||||
|
|
||||||
|
ap = SimpleNamespace(logger=Mock())
|
||||||
|
pm = ap.persistence_mgr = PersistenceManager(ap)
|
||||||
|
pm.db = SimpleNamespace(get_engine=lambda: engine)
|
||||||
|
manager = ap.model_mgr = ModelManager(ap)
|
||||||
|
contexts = {workspace: ExecutionContext('instance', workspace, 1) for workspace in ('a', 'b')}
|
||||||
|
# Only execution binding discovery is stubbed; cache indexing/removal/close is real.
|
||||||
|
manager.resolve_execution_context = AsyncMock(side_effect=lambda context: contexts[context])
|
||||||
|
service = ModelProviderService(ap)
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
async def snapshot():
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
return {
|
||||||
|
table.__tablename__: [dict(row) for row in (await conn.execute(sa.select(table))).mappings()]
|
||||||
|
for table in TABLES
|
||||||
|
}
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES):
|
||||||
|
await conn.run_sync(table.__table__.create)
|
||||||
|
for workspace in contexts:
|
||||||
|
await conn.execute(
|
||||||
|
sa.insert(Workspace).values(
|
||||||
|
uuid=workspace,
|
||||||
|
instance_uuid='instance',
|
||||||
|
name=workspace,
|
||||||
|
slug=workspace,
|
||||||
|
source='cloud_projection',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for provider, workspace in (('target', 'a'), ('neighbor', 'a'), ('foreign', 'b'), ('empty', 'a')):
|
||||||
|
await conn.execute(
|
||||||
|
sa.insert(ModelProvider).values(
|
||||||
|
uuid=provider,
|
||||||
|
workspace_uuid=workspace,
|
||||||
|
name=provider,
|
||||||
|
requester='openai-codex',
|
||||||
|
base_url='https://chatgpt.com/backend-api/codex',
|
||||||
|
api_keys=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
sa.insert(CodexCredential).values(
|
||||||
|
provider_uuid=provider,
|
||||||
|
workspace_uuid=workspace,
|
||||||
|
payload={'synthetic': provider},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(provider=provider):
|
||||||
|
# A separate connection must observe the durable deletion before close runs.
|
||||||
|
state = await snapshot()
|
||||||
|
assert all(row['uuid'] != provider for row in state['model_providers'])
|
||||||
|
assert pm.current_session() is None
|
||||||
|
closed.append(provider)
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(requester=SimpleNamespace(aclose=AsyncMock(side_effect=close)))
|
||||||
|
manager._cache_set(manager.provider_dict, manager._cache_key(contexts[workspace], provider), runtime)
|
||||||
|
if provider == 'empty':
|
||||||
|
continue
|
||||||
|
for model_type, cache in zip(
|
||||||
|
MODEL_TYPES,
|
||||||
|
(
|
||||||
|
manager.llm_model_dict,
|
||||||
|
manager.embedding_model_dict,
|
||||||
|
manager.rerank_model_dict,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for index in range(2):
|
||||||
|
uuid = f'{provider}-{model_type.__tablename__}-{index}'
|
||||||
|
await conn.execute(
|
||||||
|
sa.insert(model_type).values(
|
||||||
|
uuid=uuid,
|
||||||
|
workspace_uuid=workspace,
|
||||||
|
provider_uuid=provider,
|
||||||
|
name=uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manager._cache_set(cache, manager._cache_key(contexts[workspace], uuid), object())
|
||||||
|
initial = await snapshot()
|
||||||
|
initial_caches = [
|
||||||
|
dict(cache)
|
||||||
|
for cache in (
|
||||||
|
manager.provider_dict,
|
||||||
|
manager.llm_model_dict,
|
||||||
|
manager.embedding_model_dict,
|
||||||
|
manager.rerank_model_dict,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
yield SimpleNamespace(
|
||||||
|
ap=ap,
|
||||||
|
pm=pm,
|
||||||
|
engine=engine,
|
||||||
|
service=service,
|
||||||
|
manager=manager,
|
||||||
|
snapshot=snapshot,
|
||||||
|
initial=initial,
|
||||||
|
initial_caches=initial_caches,
|
||||||
|
closed=closed,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def assert_caches_unchanged(deletion):
|
||||||
|
assert deletion.closed == []
|
||||||
|
assert deletion.initial_caches == [
|
||||||
|
dict(cache)
|
||||||
|
for cache in (
|
||||||
|
deletion.manager.provider_dict,
|
||||||
|
deletion.manager.llm_model_dict,
|
||||||
|
deletion.manager.embedding_model_dict,
|
||||||
|
deletion.manager.rerank_model_dict,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('mode', [PersistenceMode.OSS_COMPAT, PersistenceMode.CLOUD_RUNTIME])
|
||||||
|
async def test_cascade_deletes_all_model_types_and_credentials_after_commit(deletion, mode):
|
||||||
|
deletion.pm.mode = mode
|
||||||
|
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||||
|
state = await deletion.snapshot()
|
||||||
|
for table, rows in deletion.initial.items():
|
||||||
|
identity = 'uuid' if table == 'model_providers' else 'provider_uuid'
|
||||||
|
assert state[table] == [row for row in rows if row[identity] != 'target']
|
||||||
|
assert deletion.closed == ['target']
|
||||||
|
deletion.ap.logger.warning.assert_not_called()
|
||||||
|
for cache in (
|
||||||
|
deletion.manager.provider_dict,
|
||||||
|
deletion.manager.llm_model_dict,
|
||||||
|
deletion.manager.embedding_model_dict,
|
||||||
|
deletion.manager.rerank_model_dict,
|
||||||
|
):
|
||||||
|
assert all(not key[-1].startswith('target') for key in cache)
|
||||||
|
assert any(key[1] == 'b' for key in cache)
|
||||||
|
assert any(key[-1].startswith('neighbor') for key in cache)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('model_type', MODEL_TYPES)
|
||||||
|
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}])
|
||||||
|
async def test_default_guard_preserves_each_model_type(deletion, model_type, kwargs):
|
||||||
|
async with deletion.engine.begin() as conn:
|
||||||
|
for other in MODEL_TYPES:
|
||||||
|
if other is not model_type:
|
||||||
|
await conn.execute(sa.delete(other).where(other.provider_uuid == 'target'))
|
||||||
|
before = await deletion.snapshot()
|
||||||
|
with pytest.raises(ValueError, match='models still reference it'):
|
||||||
|
await deletion.service.delete_provider('a', 'target', **kwargs)
|
||||||
|
assert await deletion.snapshot() == before
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}, {'cascade': True}])
|
||||||
|
async def test_empty_provider_deletes_credentials_with_or_without_cascade(deletion, kwargs):
|
||||||
|
await deletion.service.delete_provider('a', 'empty', **kwargs)
|
||||||
|
state = await deletion.snapshot()
|
||||||
|
assert all(row['uuid'] != 'empty' for row in state['model_providers'])
|
||||||
|
assert all(row['provider_uuid'] != 'empty' for row in state['codex_credentials'])
|
||||||
|
assert deletion.closed == ['empty']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('provider', ['foreign', 'missing'])
|
||||||
|
@pytest.mark.parametrize('cascade', [False, True])
|
||||||
|
async def test_foreign_and_missing_provider_are_non_enumerating(deletion, provider, cascade):
|
||||||
|
with pytest.raises(WorkspaceNotFoundError, match='Provider not found'):
|
||||||
|
await deletion.service.delete_provider('a', provider, cascade=cascade)
|
||||||
|
assert await deletion.snapshot() == deletion.initial
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('cascade', [False, True])
|
||||||
|
async def test_cloud_managed_provider_cannot_be_deleted(deletion, cascade):
|
||||||
|
async with deletion.engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
sa.update(ModelProvider)
|
||||||
|
.where(ModelProvider.uuid == 'target')
|
||||||
|
.values(
|
||||||
|
requester='space-chat-completions',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
before = await deletion.snapshot()
|
||||||
|
deletion.pm.mode = PersistenceMode.CLOUD_RUNTIME
|
||||||
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
|
await deletion.service.delete_provider('a', 'target', cascade=cascade)
|
||||||
|
assert await deletion.snapshot() == before
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('failure_table', ['embedding_models', 'codex_credentials', 'model_providers'])
|
||||||
|
async def test_database_failure_rolls_back_all_rows_without_runtime_cleanup(deletion, failure_table):
|
||||||
|
async with deletion.engine.begin() as conn:
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
f'CREATE TRIGGER fail_delete BEFORE DELETE ON {failure_table} '
|
||||||
|
"BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END"
|
||||||
|
)
|
||||||
|
with pytest.raises(sa.exc.IntegrityError, match='injected delete failure'):
|
||||||
|
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||||
|
assert await deletion.snapshot() == deletion.initial
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('rollback', [False, True])
|
||||||
|
async def test_nested_transaction_defers_cleanup_until_outer_commit(deletion, rollback):
|
||||||
|
class Abort(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with deletion.pm.tenant_uow('a'):
|
||||||
|
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
|
if rollback:
|
||||||
|
raise Abort
|
||||||
|
except Abort:
|
||||||
|
pass
|
||||||
|
tasks = tuple(deletion.service._deletion_tasks)
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if rollback:
|
||||||
|
assert await deletion.snapshot() == deletion.initial
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
|
else:
|
||||||
|
assert deletion.closed == ['target']
|
||||||
|
deletion.ap.logger.warning.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cascade_ignores_foreign_workspace_references_even_without_foreign_keys(deletion):
|
||||||
|
async with deletion.engine.connect() as conn:
|
||||||
|
await conn.exec_driver_sql('PRAGMA foreign_keys=OFF')
|
||||||
|
for model_type in MODEL_TYPES:
|
||||||
|
await conn.execute(
|
||||||
|
sa.update(model_type)
|
||||||
|
.where(model_type.workspace_uuid == 'b')
|
||||||
|
.values(
|
||||||
|
provider_uuid='target',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||||
|
state = await deletion.snapshot()
|
||||||
|
for model_type in MODEL_TYPES:
|
||||||
|
assert len([row for row in state[model_type.__tablename__] if row['workspace_uuid'] == 'b']) == 2
|
||||||
|
assert all(row['provider_uuid'] != 'target' for row in state['codex_credentials'])
|
||||||
|
assert deletion.closed == ['target']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def route_app(deletion):
|
||||||
|
ap = deletion.ap
|
||||||
|
ap.user_service = SimpleNamespace(
|
||||||
|
get_authenticated_account=AsyncMock(
|
||||||
|
return_value=SimpleNamespace(uuid='account', user='owner@example.invalid'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = SimpleNamespace(uuid='membership', role='owner', projection_revision=0)
|
||||||
|
ap.workspace_collaboration_service = SimpleNamespace(
|
||||||
|
resolve_account_workspace=AsyncMock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
workspace=SimpleNamespace(uuid='a'),
|
||||||
|
membership=membership,
|
||||||
|
execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ap.provider_service = SimpleNamespace(delete_provider=AsyncMock())
|
||||||
|
app = quart.Quart(__name__)
|
||||||
|
await ModelProvidersRouterGroup(ap, app).initialize()
|
||||||
|
return app.test_client(), ap.provider_service.delete_provider, membership
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('query, expected', [('', None), ('?cascade=true', True), ('?cascade=false', False)])
|
||||||
|
async def test_route_passes_explicit_cascade_and_trusted_workspace(route_app, query, expected):
|
||||||
|
client, delete, _ = route_app
|
||||||
|
response = await client.delete(
|
||||||
|
'/api/v1/provider/providers/target' + query,
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Bearer token',
|
||||||
|
'X-Workspace-Id': 'a',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert delete.await_count == 1
|
||||||
|
assert delete.await_args.args[0].workspace_uuid == 'a'
|
||||||
|
assert delete.await_args.args[1] == 'target'
|
||||||
|
assert delete.await_args.kwargs == ({} if expected is None else {'cascade': expected})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'query',
|
||||||
|
[
|
||||||
|
'?cascade=',
|
||||||
|
'?cascade',
|
||||||
|
'?cascade=TRUE',
|
||||||
|
'?cascade=1',
|
||||||
|
'?cascade=yes',
|
||||||
|
'?cascade=null',
|
||||||
|
'?cascade=%20true',
|
||||||
|
'?cascade=true&cascade=false',
|
||||||
|
'?cascade=true&cascade=true',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_route_rejects_invalid_or_duplicate_cascade_before_deletion(route_app, query):
|
||||||
|
client, delete, _ = route_app
|
||||||
|
response = await client.delete(
|
||||||
|
'/api/v1/provider/providers/target' + query,
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Bearer token',
|
||||||
|
'X-Workspace-Id': 'a',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
delete.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('role', ['viewer', 'operator'])
|
||||||
|
async def test_cascade_requires_workspace_resource_manage_permission(route_app, role):
|
||||||
|
client, delete, membership = route_app
|
||||||
|
membership.role = role
|
||||||
|
response = await client.delete(
|
||||||
|
'/api/v1/provider/providers/target?cascade=true',
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Bearer token',
|
||||||
|
'X-Workspace-Id': 'a',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
delete.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'provider, query, status',
|
||||||
|
[
|
||||||
|
('target', '', 400),
|
||||||
|
('target', '?cascade=false', 400),
|
||||||
|
('target', '?cascade=true', 200),
|
||||||
|
('foreign', '?cascade=true', 404),
|
||||||
|
('missing', '?cascade=true', 404),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_route_to_real_sqlite_service(deletion, route_app, provider, query, status):
|
||||||
|
client, _, _ = route_app
|
||||||
|
deletion.ap.provider_service = deletion.service
|
||||||
|
# The route forwards RequestContext, unlike the string-context service tests.
|
||||||
|
deletion.manager.resolve_execution_context = AsyncMock(
|
||||||
|
side_effect=lambda context: ExecutionContext(
|
||||||
|
context.instance_uuid,
|
||||||
|
context.workspace_uuid,
|
||||||
|
context.placement_generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = await client.delete(
|
||||||
|
'/api/v1/provider/providers/' + provider + query,
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Bearer token',
|
||||||
|
'X-Workspace-Id': 'a',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == status
|
||||||
|
if status == 200:
|
||||||
|
assert deletion.closed == ['target']
|
||||||
|
for model_type in MODEL_TYPES:
|
||||||
|
assert all(
|
||||||
|
row['provider_uuid'] != 'target' for row in (await deletion.snapshot())[model_type.__tablename__]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert await deletion.snapshot() == deletion.initial
|
||||||
|
assert_caches_unchanged(deletion)
|
||||||
@@ -14,11 +14,12 @@ Source: src/langbot/pkg/api/http/service/provider.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from contextlib import nullcontext
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.provider import ModelProviderService
|
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||||
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
|
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
|
||||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
|
||||||
|
|
||||||
@@ -383,112 +384,35 @@ class TestModelProviderServiceUpdateProvider:
|
|||||||
|
|
||||||
|
|
||||||
class TestModelProviderServiceDeleteProvider:
|
class TestModelProviderServiceDeleteProvider:
|
||||||
"""Tests for delete_provider method."""
|
"""Fast guard coverage; real transaction/cache behavior is in test_provider_cascade."""
|
||||||
|
|
||||||
async def test_delete_provider_with_llm_models_raises_error(self):
|
|
||||||
"""Raises ValueError when LLM models reference provider."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
|
|
||||||
# Mock LLM model exists - only return LLM result since that's first check
|
|
||||||
llm_result = _create_mock_result([], first_item=_create_mock_llm_model())
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=llm_result)
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('label', ['LLM', 'Embedding', 'Rerank', None])
|
||||||
|
async def test_delete_provider_requires_no_references(self, label):
|
||||||
|
provider_result = Mock()
|
||||||
|
provider_result.first.return_value = SimpleNamespace(requester='openai')
|
||||||
|
results = [provider_result]
|
||||||
|
for model_label in ('LLM', 'Embedding', 'Rerank'):
|
||||||
|
result = Mock()
|
||||||
|
result.scalars.return_value = ['model'] if label == model_label else []
|
||||||
|
results.append(result)
|
||||||
|
results.extend([Mock(rowcount=1), Mock(rowcount=1)])
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
persistence_mgr=SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(side_effect=results),
|
||||||
|
tenant_uow=lambda _: nullcontext(),
|
||||||
|
tenant_scope=lambda _: nullcontext(),
|
||||||
|
current_session=lambda: None,
|
||||||
|
),
|
||||||
|
model_mgr=SimpleNamespace(remove_provider=AsyncMock()),
|
||||||
|
)
|
||||||
service = ModelProviderService(ap)
|
service = ModelProviderService(ap)
|
||||||
|
if label is not None:
|
||||||
# Execute & Verify
|
with pytest.raises(ValueError, match=f'Cannot delete provider: {label} models'):
|
||||||
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
|
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm')
|
ap.model_mgr.remove_provider.assert_not_awaited()
|
||||||
|
else:
|
||||||
async def test_delete_provider_with_embedding_models_raises_error(self):
|
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||||
"""Raises ValueError when Embedding models reference provider."""
|
ap.model_mgr.remove_provider.assert_awaited_once_with(WORKSPACE_UUID, 'provider')
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
|
|
||||||
# Create results for each check type
|
|
||||||
llm_result = Mock()
|
|
||||||
llm_result.first = Mock(return_value=None) # No LLM models
|
|
||||||
embedding_result = Mock()
|
|
||||||
embedding_result.first = Mock(return_value=Mock(spec=EmbeddingModel)) # Has embedding model
|
|
||||||
rerank_result = Mock()
|
|
||||||
rerank_result.first = Mock(return_value=None)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def mock_execute(query):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return llm_result
|
|
||||||
elif call_count == 2:
|
|
||||||
return embedding_result
|
|
||||||
return rerank_result
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
||||||
|
|
||||||
service = ModelProviderService(ap)
|
|
||||||
|
|
||||||
# Execute & Verify - should raise embedding error (LLM check passes, embedding check fails)
|
|
||||||
with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'):
|
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-embedding')
|
|
||||||
|
|
||||||
async def test_delete_provider_with_rerank_models_raises_error(self):
|
|
||||||
"""Raises ValueError when Rerank models reference provider."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
|
|
||||||
# Create results for each check type
|
|
||||||
llm_result = Mock()
|
|
||||||
llm_result.first = Mock(return_value=None) # No LLM models
|
|
||||||
embedding_result = Mock()
|
|
||||||
embedding_result.first = Mock(return_value=None) # No embedding models
|
|
||||||
rerank_result = Mock()
|
|
||||||
rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def mock_execute(query):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return llm_result
|
|
||||||
elif call_count == 2:
|
|
||||||
return embedding_result
|
|
||||||
return rerank_result
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
||||||
|
|
||||||
service = ModelProviderService(ap)
|
|
||||||
|
|
||||||
# Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails)
|
|
||||||
with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'):
|
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-rerank')
|
|
||||||
|
|
||||||
async def test_delete_provider_no_models_success(self):
|
|
||||||
"""Deletes provider when no models reference it."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
ap.model_mgr = SimpleNamespace()
|
|
||||||
ap.model_mgr.remove_provider = AsyncMock()
|
|
||||||
|
|
||||||
# Mock no models reference provider
|
|
||||||
empty_result = Mock()
|
|
||||||
empty_result.first = Mock(return_value=None)
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=empty_result)
|
|
||||||
|
|
||||||
service = ModelProviderService(ap)
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'provider-no-models')
|
|
||||||
|
|
||||||
# Verify - delete and remove called
|
|
||||||
ap.model_mgr.remove_provider.assert_called_once_with(WORKSPACE_UUID, 'provider-no-models')
|
|
||||||
|
|
||||||
|
|
||||||
class TestModelProviderServiceGetProviderModelCounts:
|
class TestModelProviderServiceGetProviderModelCounts:
|
||||||
@@ -1045,15 +969,18 @@ class TestCloudManagedProviderProtection:
|
|||||||
|
|
||||||
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
||||||
service = self._service()
|
service = self._service()
|
||||||
service.get_provider = AsyncMock(
|
service.get_provider = AsyncMock(return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER})
|
||||||
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
||||||
|
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
service.ap.persistence_mgr.tenant_uow = lambda _: nullcontext()
|
||||||
|
result = Mock()
|
||||||
|
result.first.return_value = SimpleNamespace(requester=SYSTEM_REQUESTER)
|
||||||
|
service.ap.persistence_mgr.execute_async.return_value = result
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
||||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
assert service.ap.persistence_mgr.execute_async.await_count == 1
|
||||||
|
|
||||||
async def test_oss_does_not_reserve_space_requester(self):
|
async def test_oss_does_not_reserve_space_requester(self):
|
||||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
|
|||||||
result = service.get_oauth_authorize_url('http://localhost/callback')
|
result = service.get_oauth_authorize_url('http://localhost/callback')
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
|
query = parse_qs(urlsplit(result).query)
|
||||||
|
assert query['redirect_uri'] == ['http://localhost/callback']
|
||||||
|
assert query['code_contract'] == ['redirect-v1']
|
||||||
assert 'https://space.langbot.app/auth/authorize' in result
|
assert 'https://space.langbot.app/auth/authorize' in result
|
||||||
|
|
||||||
def test_get_oauth_authorize_url_with_state(self):
|
def test_get_oauth_authorize_url_with_state(self):
|
||||||
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
|
|||||||
'auth_code',
|
'auth_code',
|
||||||
['workspace-1'],
|
['workspace-1'],
|
||||||
{'workspace-1': 1_700_000_000},
|
{'workspace-1': 1_700_000_000},
|
||||||
|
redirect_uri='https://oss.example/auth/space/callback',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert result['access_token'] == 'new_access_token'
|
assert result['access_token'] == 'new_access_token'
|
||||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||||
'code': 'auth_code',
|
'code': 'auth_code',
|
||||||
|
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
'workspace_uuids': ['workspace-1'],
|
'workspace_uuids': ['workspace-1'],
|
||||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||||
@@ -846,10 +850,7 @@ class TestSpaceServiceGetModelSelection:
|
|||||||
if response_shape == 'models-envelope':
|
if response_shape == 'models-envelope':
|
||||||
data = {'models': models}
|
data = {'models': models}
|
||||||
elif response_shape == 'availability-wrapper':
|
elif response_shape == 'availability-wrapper':
|
||||||
data = [
|
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
|
||||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
|
||||||
for index, model in enumerate(models)
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
data = models
|
data = models
|
||||||
payload = {'code': 0, 'data': data}
|
payload = {'code': 0, 'data': data}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
@@ -214,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
|
|||||||
request_sync.assert_called_once_with()
|
request_sync.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
|
||||||
|
application, session_factory = projection_context
|
||||||
|
provider = _Provider(
|
||||||
|
[_snapshot(7, workspaces=[])],
|
||||||
|
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||||
|
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||||
|
membership = await session.scalar(
|
||||||
|
sqlalchemy.select(WorkspaceMembership).where(
|
||||||
|
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
|
||||||
|
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||||
|
assert account is not None
|
||||||
|
assert workspace is not None and workspace.name == 'JIT Workspace'
|
||||||
|
assert membership is not None and membership.status == 'active'
|
||||||
|
assert state is not None and state.cursor == 7
|
||||||
|
assert provider.delta_calls == 1
|
||||||
|
assert provider.after_cursors == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
|
||||||
|
application, session_factory = projection_context
|
||||||
|
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
|
||||||
|
update={
|
||||||
|
'members': [
|
||||||
|
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
provider = _Provider(
|
||||||
|
[_snapshot(7)],
|
||||||
|
deltas=[_delta(workspaces=[targeted_workspace])],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||||
|
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||||
|
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||||
|
assert account is not None and account.user == 'Workspace Owner'
|
||||||
|
assert account.projection_revision == 7
|
||||||
|
assert workspace is not None and workspace.name == 'Renamed Workspace'
|
||||||
|
assert state is not None and state.cursor == 7
|
||||||
|
|
||||||
|
|
||||||
|
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
|
||||||
|
application, _session_factory = projection_context
|
||||||
|
provider = _Provider(
|
||||||
|
[_snapshot(7, workspaces=[])],
|
||||||
|
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
service._reconcile_entitlement_snapshot_set = AsyncMock()
|
||||||
|
service._update_entitlement_workspace_activity = AsyncMock()
|
||||||
|
service._publish_runtime_execution_projection = Mock()
|
||||||
|
|
||||||
|
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
|
||||||
|
service._update_entitlement_workspace_activity.assert_awaited_once()
|
||||||
|
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
|
||||||
|
'requested_workspace_uuids': {WORKSPACE_UUID},
|
||||||
|
}
|
||||||
|
service._publish_runtime_execution_projection.assert_called_once()
|
||||||
|
assert service._publish_runtime_execution_projection.call_args.kwargs == {
|
||||||
|
'affected_workspace_uuids': {WORKSPACE_UUID},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
reconcile_execution_projection = Mock()
|
reconcile_execution_projection = Mock()
|
||||||
@@ -735,6 +818,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
|
|||||||
assert second_provider.after_cursors == [1, 2]
|
assert second_provider.after_cursors == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
|
||||||
|
application, _session_factory = projection_context
|
||||||
|
|
||||||
|
class _ConcurrentProvider(_Provider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__([_snapshot(1)])
|
||||||
|
self.first_fetch_started = asyncio.Event()
|
||||||
|
self.release_first_fetch = asyncio.Event()
|
||||||
|
self.active_fetches = 0
|
||||||
|
self.max_active_fetches = 0
|
||||||
|
|
||||||
|
async def fetch_events(
|
||||||
|
self,
|
||||||
|
instance_uuid: str,
|
||||||
|
after_cursor: int,
|
||||||
|
limit: int,
|
||||||
|
) -> DirectoryEventBatch:
|
||||||
|
assert instance_uuid == INSTANCE_UUID
|
||||||
|
assert limit == 100
|
||||||
|
self.after_cursors.append(after_cursor)
|
||||||
|
self.active_fetches += 1
|
||||||
|
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
|
||||||
|
try:
|
||||||
|
if len(self.after_cursors) == 1:
|
||||||
|
self.first_fetch_started.set()
|
||||||
|
await self.release_first_fetch.wait()
|
||||||
|
cursor = after_cursor + 1
|
||||||
|
return DirectoryEventBatch(
|
||||||
|
instance_uuid=instance_uuid,
|
||||||
|
after_cursor=after_cursor,
|
||||||
|
cursor=cursor,
|
||||||
|
high_water_cursor=cursor,
|
||||||
|
events=(
|
||||||
|
DirectoryEvent(
|
||||||
|
cursor=cursor,
|
||||||
|
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
|
||||||
|
aggregate_uuid=WORKSPACE_UUID,
|
||||||
|
event_type='entitlement.changed',
|
||||||
|
revision=cursor,
|
||||||
|
payload={
|
||||||
|
'workspace_uuid': WORKSPACE_UUID,
|
||||||
|
'entitlement_revision': cursor,
|
||||||
|
},
|
||||||
|
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.active_fetches -= 1
|
||||||
|
|
||||||
|
provider = _ConcurrentProvider()
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
first = asyncio.create_task(service.sync_once())
|
||||||
|
await provider.first_fetch_started.wait()
|
||||||
|
second = asyncio.create_task(service.sync_once())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
provider.release_first_fetch.set()
|
||||||
|
await asyncio.gather(first, second)
|
||||||
|
|
||||||
|
assert provider.max_active_fetches == 1
|
||||||
|
assert provider.after_cursors == [1, 2]
|
||||||
|
assert service._consumer_cursor == 3
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
event_two = DirectoryEvent(
|
event_two = DirectoryEvent(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Keep subprocess coverage pointed at the generated E2E configuration."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from tests.e2e.utils.process_manager import LangBotProcess
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_coverage_environment_uses_generated_config(tmp_path):
|
||||||
|
process = Mock()
|
||||||
|
process.poll.return_value = None
|
||||||
|
project = tmp_path / 'project'
|
||||||
|
project.mkdir()
|
||||||
|
manager = LangBotProcess(project, tmp_path, collect_coverage=True)
|
||||||
|
with patch('subprocess.Popen', return_value=process) as popen, patch('httpx.get') as get:
|
||||||
|
get.return_value.status_code = 200
|
||||||
|
assert manager.start()
|
||||||
|
config = Path(popen.call_args.kwargs['env']['COVERAGE_PROCESS_START'])
|
||||||
|
assert config.is_file()
|
||||||
|
assert f'--rcfile={config}' in popen.call_args.args[0]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Real rollback-journal contention must not poison the pooled writer."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import sqlite3
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSessionTransaction, create_async_engine
|
||||||
|
|
||||||
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||||
|
from langbot.pkg.persistence.tenant_uow import TenantScopedAsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('cancel_commit', [False, True])
|
||||||
|
@pytest.mark.parametrize('close_fails', [False, True])
|
||||||
|
@pytest.mark.parametrize('cancel_cleanup', [False, True])
|
||||||
|
async def test_failed_commit_releases_sqlite_writer_and_scope(
|
||||||
|
tmp_path, monkeypatch, cancel_commit, close_fails, cancel_cleanup
|
||||||
|
):
|
||||||
|
path = tmp_path / 'failed-commit.db'
|
||||||
|
engine = create_async_engine(
|
||||||
|
f'sqlite+aiosqlite:///{path}', connect_args={'timeout': 0.05}, pool_size=1, max_overflow=0
|
||||||
|
)
|
||||||
|
table = sa.Table('rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||||
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||||
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||||
|
original_commit = AsyncSessionTransaction.commit
|
||||||
|
original_error = None
|
||||||
|
invalidation_finished = False
|
||||||
|
owner = asyncio.current_task()
|
||||||
|
original_invalidate = AsyncConnection.invalidate
|
||||||
|
|
||||||
|
async def delayed_invalidate(connection, exception=None):
|
||||||
|
nonlocal invalidation_finished
|
||||||
|
if cancel_cleanup:
|
||||||
|
owner.cancel()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
owner.cancel()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await original_invalidate(connection, exception)
|
||||||
|
invalidation_finished = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(AsyncConnection, 'invalidate', delayed_invalidate)
|
||||||
|
|
||||||
|
async def failing_commit(transaction):
|
||||||
|
nonlocal original_error
|
||||||
|
try:
|
||||||
|
await original_commit(transaction)
|
||||||
|
except sa.exc.OperationalError as exc:
|
||||||
|
original_error = asyncio.CancelledError('commit cancelled') if cancel_commit else exc
|
||||||
|
raise original_error
|
||||||
|
|
||||||
|
monkeypatch.setattr(AsyncSessionTransaction, 'commit', failing_commit)
|
||||||
|
original_close = TenantScopedAsyncSession._close_owned_session
|
||||||
|
|
||||||
|
async def failing_close(session, capability):
|
||||||
|
await original_close(session, capability)
|
||||||
|
if original_error is not None:
|
||||||
|
raise RuntimeError('secondary close failure')
|
||||||
|
|
||||||
|
if close_fails:
|
||||||
|
monkeypatch.setattr(TenantScopedAsyncSession, '_close_owned_session', failing_close)
|
||||||
|
blocker = sqlite3.connect(path, timeout=0.05)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(table.metadata.create_all)
|
||||||
|
await connection.execute(sa.insert(table).values(id=1))
|
||||||
|
blocker.execute('BEGIN')
|
||||||
|
blocker.execute('SELECT * FROM rows').fetchall()
|
||||||
|
error_type = asyncio.CancelledError if cancel_commit else sa.exc.OperationalError
|
||||||
|
with pytest.raises(error_type) as caught:
|
||||||
|
async with manager.tenant_uow('workspace-a') as outer:
|
||||||
|
gate = manager.create_after_commit_gate()
|
||||||
|
state = outer._active_state
|
||||||
|
async with manager.tenant_uow('workspace-a') as inner:
|
||||||
|
assert inner.session is outer.session
|
||||||
|
await manager.execute_async(sa.insert(table).values(id=2))
|
||||||
|
assert not gate.done()
|
||||||
|
assert state.depth == 1
|
||||||
|
assert caught.value is original_error
|
||||||
|
assert invalidation_finished
|
||||||
|
if cancel_cleanup:
|
||||||
|
# Do not leak the synthetic cancellation count into pytest.
|
||||||
|
owner.uncancel()
|
||||||
|
owner.uncancel()
|
||||||
|
monkeypatch.setattr(TenantScopedAsyncSession, '_close_owned_session', original_close)
|
||||||
|
if close_fails:
|
||||||
|
assert any('secondary close failure' in note for note in caught.value.__notes__)
|
||||||
|
assert gate.cancelled()
|
||||||
|
assert state.depth == 0
|
||||||
|
assert manager.current_session() is None
|
||||||
|
with pytest.raises(RuntimeError, match='not active'):
|
||||||
|
_ = outer.session
|
||||||
|
|
||||||
|
# The original SHARED lock remains. New reads and RESERVED writes
|
||||||
|
# must work; COMMIT of another write must wait for its release.
|
||||||
|
assert blocker.in_transaction
|
||||||
|
with contextlib.closing(sqlite3.connect(path, timeout=0.05)) as probe:
|
||||||
|
assert probe.execute('SELECT id FROM rows').fetchall() == [(1,)]
|
||||||
|
probe.execute('INSERT INTO rows VALUES (3)')
|
||||||
|
probe.rollback()
|
||||||
|
async with manager.tenant_uow('workspace-b'):
|
||||||
|
assert (await manager.execute_async(sa.select(table.c.id))).scalars().all() == [1]
|
||||||
|
blocker.rollback()
|
||||||
|
async with manager.tenant_uow('workspace-b'):
|
||||||
|
await manager.execute_async(sa.insert(table).values(id=4))
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
assert (await connection.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all() == [1, 4]
|
||||||
|
assert engine.pool.checkedout() == 0
|
||||||
|
finally:
|
||||||
|
blocker.close()
|
||||||
|
await engine.dispose()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user