mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 17:47:14 +00:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fe4b117a4 | |||
| cba842fbee | |||
| a61b0f0068 | |||
| c15f668126 | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 | |||
| e69a80f5e9 | |||
| bafdaf0033 | |||
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d | |||
| 1f1a3aff55 | |||
| de28b3160c | |||
| 79773b669a | |||
| 5084f2391d | |||
| b121c66f18 | |||
| 536534865e | |||
| ff528e8e0b | |||
| f7914a8900 | |||
| c18fc9dfe3 | |||
| bc40418bda | |||
| 45ed6efceb | |||
| c67a503532 | |||
| 8b14d2ab8e | |||
| 97428310b9 | |||
| 338ee733cb | |||
| c3299fd1a6 | |||
| be8478e940 | |||
| 96a535827f | |||
| 9b81a0b606 | |||
| bbc912d0ef | |||
| 20710df9cb | |||
| 90f3d880e5 | |||
| e37987215e | |||
| 22c389edc1 | |||
| 78068db9c8 | |||
| 7dc9dafb7c | |||
| 4bd899e77b | |||
| ddb6dbf593 | |||
| f59343fd5b | |||
| 211710e24c | |||
| cdd5c6589c | |||
| 3b4698463c | |||
| edd6cad449 | |||
| d78546967c | |||
| c08bfc8ced | |||
| 7820949d3a | |||
| e263a5d1d7 | |||
| 6bad7bcffc | |||
| f0b2c103c1 | |||
| 3101c9be6a | |||
| 1e6e4c0ca7 | |||
| 408c8031d4 | |||
| 0e6cca4690 | |||
| a7a7218afe | |||
| a67728c163 | |||
| e9c9e896c6 | |||
| e2331c4967 | |||
| 0ccbcd5f5f | |||
| c5aada494d | |||
| e36e3aaea8 | |||
| d64278ab3f | |||
| 161ea9b3eb | |||
| c7d14676fc | |||
| 2456bf1350 | |||
| 05a941ff16 | |||
| 0330788d14 | |||
| d8ab0ba567 | |||
| e3832ca536 | |||
| 5d9fd15671 | |||
| 404e3466d9 | |||
| 473ba573a3 | |||
| 93dbd3541e | |||
| a5a26f81ee | |||
| 92d9db8f95 | |||
| 59db012594 | |||
| 88f328066b | |||
| d155d9d5a8 | |||
| dd95545309 | |||
| 9066c25729 | |||
| 6d2e9d3d72 | |||
| a0b85e11fd | |||
| 122d8fa659 | |||
| ace8cc67f2 | |||
| 52c0772806 | |||
| d5044c2f1e | |||
| 7baa89254c |
@@ -1,5 +1,5 @@
|
||||
name: 漏洞反馈
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
@@ -22,7 +22,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 异常情况
|
||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://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]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
|
||||
@@ -7,23 +7,42 @@ on:
|
||||
jobs:
|
||||
build-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
# 如果是tag则跳过
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Tag
|
||||
id: generate_tag
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate image metadata
|
||||
id: image
|
||||
shell: bash
|
||||
run: |
|
||||
# 获取分支名称,把/替换为-
|
||||
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
||||
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
||||
- name: Login to Registry
|
||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx create --name mybuilder --use
|
||||
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
||||
set -euo pipefail
|
||||
branch_tag="${GITHUB_REF#refs/heads/}"
|
||||
branch_tag="${branch_tag//\//-}"
|
||||
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push immutable Core image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
||||
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
|
||||
## Where to Look
|
||||
|
||||
- Architecture map: `ARCHITECTURE.md`.
|
||||
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
||||
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
|
||||
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
||||
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
|
||||
|
||||
+16
-7
@@ -169,14 +169,17 @@ The Plugin Runtime supports stdio and WebSocket control transports. Direct local
|
||||
|
||||
## Box Runtime and Skills
|
||||
|
||||
Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes.
|
||||
Box is the optional sandbox subsystem used by native execution tools, stdio MCP servers, agent-side skill authoring, and managed processes. Skill storage and read-only access are Core responsibilities and remain available without Box.
|
||||
|
||||
In this repo:
|
||||
|
||||
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles.
|
||||
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic mounts, and sandbox profiles.
|
||||
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
|
||||
- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability.
|
||||
- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed.
|
||||
- `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the
|
||||
Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
|
||||
and execution-backed tools depend on Box availability.
|
||||
- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; `skills.root` owns its location independently of Box.
|
||||
- `pkg/skill/manager.py` caches the Core repository catalog for progressive disclosure. Activation and read-only resource tools do not require Box; script execution and Workspace mutation still do.
|
||||
|
||||
Durable Box Workspace storage is shared across placement generations, but
|
||||
sandbox sessions and managed processes are generation-scoped. LangBot validates
|
||||
@@ -187,11 +190,17 @@ retires stale processes and closes already-attached relays.
|
||||
In `langbot-plugin-sdk`:
|
||||
|
||||
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
|
||||
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes.
|
||||
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions, generic read-only mounts, and managed processes.
|
||||
- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends.
|
||||
- `skill_store.py` manages skill packages from the Box side.
|
||||
- `src/langbot_plugin/skill_store.py` is consumed by Core, not Box. Core turns
|
||||
selected package roots into generic read-only mounts; Box does not understand
|
||||
Skill names, metadata, revisions, files, or CRUD.
|
||||
|
||||
Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime.
|
||||
Skill storage uses `skills.root`. Box execution config lives under `box:`:
|
||||
`box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. The old
|
||||
`box.local.skills_root` key is read only as an online-upgrade fallback and is
|
||||
marked for removal in the next major version. Start LangBot with
|
||||
`--standalone-box` when connecting to an externally launched Box runtime.
|
||||
|
||||
## HTTP API, Web UI, and MCP Server
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -62,7 +62,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -f /tmp/nodesource_setup.sh \
|
||||
&& python -m pip install --no-cache-dir uv \
|
||||
&& uv sync \
|
||||
&& uv sync --extra seekdb \
|
||||
&& apt-get purge -y --auto-remove curl git gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& touch /.dockerenv
|
||||
|
||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Website</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||
|
||||
[→ Learn more about all features](https://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/).
|
||||
|
||||
@@ -83,12 +83,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### One-Click Cloud Deploy
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -150,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||
|
||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||
[→ View all integrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官网</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">扩展市场</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://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/)。
|
||||
|
||||
@@ -83,12 +83,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### 一键云部署
|
||||
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +152,7 @@ docker compose --profile all up -d
|
||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||
|
||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
### TTS(语音合成)
|
||||
|
||||
|
||||
+7
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||
|
||||
[→ Conocer más sobre todas las funcionalidades](https://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/).
|
||||
|
||||
@@ -82,12 +82,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### Despliegue en la Nube con un Clic
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||
|
||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://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/).
|
||||
|
||||
@@ -82,12 +82,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### Déploiement Cloud en un Clic
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||
|
||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">ホーム</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||
<a href="https://langbot.app/docs/ja/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
- **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/)。
|
||||
|
||||
@@ -82,12 +82,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### ワンクリッククラウドデプロイ
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||
|
||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. 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/).
|
||||
|
||||
@@ -82,12 +82,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### 원클릭 클라우드 배포
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||
|
||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование 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/).
|
||||
|
||||
@@ -82,12 +82,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### Облачное развертывание одним кликом
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||
|
||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官網</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">外掛市場</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||
|
||||
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
- **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/)。
|
||||
|
||||
@@ -84,12 +84,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### 一鍵雲端部署
|
||||
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -164,7 +165,7 @@ docker compose --profile all up -d
|
||||
|-----------|------|
|
||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||
|
||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<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://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://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/).
|
||||
|
||||
@@ -82,12 +82,13 @@ cd LangBot/docker
|
||||
docker compose --profile all up -d
|
||||
```
|
||||
|
||||
|
||||
### Triển khai đám mây một cú nhấp
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||
|
||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+20
-15
@@ -1,5 +1,5 @@
|
||||
# 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"
|
||||
|
||||
services:
|
||||
@@ -14,8 +14,8 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# Shared with the langbot service and sent only as a WebSocket handshake
|
||||
# header. Generate with: openssl rand -hex 32
|
||||
# Optional. Leave unset on both OSS services, or set the same value on
|
||||
# both to protect the control WebSocket. Generate with: openssl rand -hex 32
|
||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||
# Process-wide admission for every asyncio.to_thread() call.
|
||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||
@@ -27,8 +27,8 @@ services:
|
||||
|
||||
# The Box sandbox runtime is optional. It is only started when you run
|
||||
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
||||
# up``). With Box off, LangBot keeps the dashboard / skills list visible
|
||||
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
|
||||
# up``). With Box off, LangBot keeps skill management, activation, and
|
||||
# read-only resources available but disables execution tools and stdio MCP —
|
||||
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
||||
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
||||
langbot_box:
|
||||
@@ -47,11 +47,10 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# Shared control-plane secret used to authenticate both the RPC socket
|
||||
# and managed-process relay. Generate once (for example with
|
||||
# ``openssl rand -hex 32``) and export it before enabling this profile.
|
||||
# An empty value is accepted by Compose so Box can remain optional, but
|
||||
# the Box runtime itself fails closed when the profile is started.
|
||||
# Optional shared control-plane secret used to authenticate both the RPC
|
||||
# socket and managed-process relay. Leave unset on both OSS services, or
|
||||
# generate one with ``openssl rand -hex 32`` and set the same value on
|
||||
# both ends. Strongly recommended when the deployment is Internet-accessible.
|
||||
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
||||
# Box has its own process-wide blocking-work budget.
|
||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||
@@ -74,14 +73,18 @@ services:
|
||||
container_name: langbot
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Core owns the SkillRepository even when the Box profile is disabled.
|
||||
# Keep this path identical to langbot_box so optional execution can
|
||||
# consume the same Workspace-scoped package revisions.
|
||||
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# Must match langbot_plugin_runtime. Empty/missing values make the
|
||||
# external control channel fail closed.
|
||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||
# Must match the value supplied to langbot_box. The token is sent only
|
||||
# in WebSocket handshake headers, never in URLs or action payloads.
|
||||
# When set, this must match langbot_box. If both ends leave it unset,
|
||||
# OSS permits the connection without token authentication. The token is
|
||||
# sent only in WebSocket handshake headers, never in URLs or payloads.
|
||||
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
||||
# Core process-wide blocking-work admission. These are native config
|
||||
# overrides and are persisted with the effective data/config.yaml.
|
||||
@@ -93,7 +96,9 @@ services:
|
||||
# box.* and are forwarded to the Box runtime via INIT RPC.
|
||||
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
||||
- BOX__LOCAL__SKILLS_ROOT=skills
|
||||
# TODO(next-major): default LANGBOT_SKILLS_ROOT to ${PWD}/data/skills
|
||||
# after the historical Box storage path no longer needs zero-copy upgrades.
|
||||
- SKILLS__ROOT=${LANGBOT_SKILLS_ROOT:-${LANGBOT_BOX_ROOT:-${PWD}/data/box}/skills}
|
||||
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kubernetes Deployment for LangBot
|
||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||
#
|
||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
|
||||
#
|
||||
# Usage:
|
||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||
@@ -214,8 +214,9 @@ spec:
|
||||
# Deployment for LangBot Box (sandbox) runtime
|
||||
#
|
||||
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
||||
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
|
||||
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# glob / grep), Skill script execution, and stdio-mode MCP servers. Skill
|
||||
# activation, resources, and management remain Core-owned without Box. Box is
|
||||
# OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||
# dashboard renders cleanly with sandbox features disabled.
|
||||
#
|
||||
@@ -448,14 +449,15 @@ spec:
|
||||
key: token
|
||||
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
||||
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||
# Deployment's box-root mountPath, so that skill package paths resolve
|
||||
# identically on both sides and on the node's Docker daemon.
|
||||
# Deployment's box-root mountPath, so generic package mount paths
|
||||
# resolve identically on both sides and on the node's Docker daemon.
|
||||
- name: BOX__LOCAL__HOST_ROOT
|
||||
value: "/app/data/box"
|
||||
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||
value: "default"
|
||||
- name: BOX__LOCAL__SKILLS_ROOT
|
||||
value: "skills"
|
||||
- name: SKILLS__ROOT
|
||||
# TODO(next-major): use /app/data/skills after the legacy path window.
|
||||
value: "/app/data/box/skills"
|
||||
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||
value: "/app/data/box"
|
||||
volumeMounts:
|
||||
|
||||
@@ -218,8 +218,8 @@ metadata:
|
||||
spec:
|
||||
categories: [popular, global]
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
config:
|
||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||
- { name: callback_url, type: string, required: false, default: "" }
|
||||
|
||||
@@ -10,6 +10,19 @@ uvx langbot
|
||||
|
||||
This will automatically download and run the latest version of LangBot.
|
||||
|
||||
SeekDB support is optional and is not installed by the command above. If you
|
||||
want to use the SeekDB vector database or the built-in SeekDB embedding model,
|
||||
run LangBot with the `seekdb` extra:
|
||||
|
||||
```bash
|
||||
uvx --from 'langbot[seekdb]@latest' langbot
|
||||
```
|
||||
|
||||
The extra includes native dependencies whose supported operating systems may
|
||||
be narrower than LangBot's. In particular, the current Apple Silicon wheels
|
||||
require macOS 15 or later. The default Chroma backend does not have this
|
||||
requirement.
|
||||
|
||||
## Install with pip/uv
|
||||
|
||||
You can also install LangBot as a regular Python package:
|
||||
@@ -20,6 +33,10 @@ pip install langbot
|
||||
|
||||
# Using uv
|
||||
uv pip install langbot
|
||||
|
||||
# Include optional SeekDB support
|
||||
pip install 'langbot[seekdb]'
|
||||
# or: uv pip install 'langbot[seekdb]'
|
||||
```
|
||||
|
||||
Then run it:
|
||||
@@ -101,7 +118,7 @@ uvx langbot
|
||||
|
||||
## System Requirements
|
||||
|
||||
- Python 3.10.1 or higher
|
||||
- Python 3.11 or higher (lower than Python 4)
|
||||
- Operating System: Linux, macOS, or Windows
|
||||
|
||||
## Differences from Source Installation
|
||||
|
||||
+35
-44
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
||||
|
||||
## Installation
|
||||
|
||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
||||
SeekDB is an optional LangBot feature. A normal LangBot installation uses
|
||||
Chroma by default and does not install `pyseekdb` or its native bindings.
|
||||
|
||||
If you need to install it manually:
|
||||
Choose the command that matches how you run LangBot:
|
||||
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
# PyPI / uvx
|
||||
uvx --from 'langbot[seekdb]@latest' langbot
|
||||
|
||||
# Installed package
|
||||
pip install 'langbot[seekdb]'
|
||||
|
||||
# Source checkout
|
||||
uv sync --extra seekdb
|
||||
```
|
||||
|
||||
## ⚠️ Platform Compatibility
|
||||
@@ -30,31 +38,36 @@ pip install pyseekdb
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
||||
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
|
||||
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
|
||||
|
||||
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
||||
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
|
||||
force-install or retag a wheel built for a newer macOS release: the bundled
|
||||
binaries also declare macOS 15 as their minimum deployment target.
|
||||
|
||||
### Server Mode (Docker)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full Docker support |
|
||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||
|
||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||
- Using ChromaDB or Qdrant as alternatives
|
||||
- Connecting to a remote SeekDB server on Linux if available
|
||||
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
||||
|
||||
### Server Mode (Remote Connection)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
||||
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
|
||||
|
||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
||||
Remote server mode does not use embedded storage at runtime. However, whether
|
||||
the Python client can be installed still depends on `pyseekdb`'s package
|
||||
metadata for the current platform.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -170,22 +183,23 @@ Key methods:
|
||||
|
||||
### Import Error
|
||||
|
||||
If you see: `ImportError: pyseekdb is not installed`
|
||||
If you see: `SeekDB support is not installed`
|
||||
|
||||
Solution:
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
uv sync --extra seekdb
|
||||
# or: uvx --from 'langbot[seekdb]@latest' langbot
|
||||
```
|
||||
|
||||
### Embedded Mode Error on macOS/Windows
|
||||
### Embedded Mode Is Unavailable on the Current Platform
|
||||
|
||||
**Error**:
|
||||
```
|
||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
||||
```
|
||||
|
||||
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
||||
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
|
||||
CPU architecture, Python version, and macOS deployment target.
|
||||
|
||||
**Solution**: Use server mode instead:
|
||||
1. Deploy SeekDB on a Linux server or VM
|
||||
@@ -208,29 +222,6 @@ vdb:
|
||||
use: chroma # or qdrant
|
||||
```
|
||||
|
||||
### Docker Container Fails on macOS
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||
# Container exits immediately with code 30
|
||||
```
|
||||
|
||||
**Error in logs**:
|
||||
```
|
||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||
```
|
||||
|
||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||
|
||||
**Status**: Under investigation by OceanBase team.
|
||||
|
||||
**Workaround Options**:
|
||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||
|
||||
### Connection Error (Server Mode)
|
||||
|
||||
If SeekDB server is not reachable, check:
|
||||
@@ -252,7 +243,7 @@ For large datasets:
|
||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||
- OceanBase Documentation: https://oceanbase.ai
|
||||
- LangBot Documentation: https://docs.langbot.app
|
||||
- LangBot Documentation: https://langbot.app/docs
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch
|
||||
- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
|
||||
- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
|
||||
|
||||
### Runtime control transports authenticate before protocol dispatch
|
||||
### Runtime control transports support opt-in shared-secret authentication
|
||||
|
||||
- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
|
||||
- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
|
||||
- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
|
||||
- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate.
|
||||
- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse.
|
||||
- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value.
|
||||
|
||||
### Dashboard WebSocket sessions are tenant runtime objects
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
│ │ │ (shared 容器, 多 process) │
|
||||
│ │ │ │
|
||||
│ │ ├──> SkillToolLoader (activate 工具) │
|
||||
│ │ │ └─ build_execution_mounts() │
|
||||
│ │ │ │
|
||||
│ │ ├──> SkillAuthoringToolLoader │
|
||||
│ │ │ │
|
||||
@@ -33,7 +34,7 @@
|
||||
│ ├─ Workspace quota 检查 │
|
||||
│ ├─ 输出截断 (head+tail) │
|
||||
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
||||
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
|
||||
│ ├─ 通用只读挂载接收 (read_only_mounts) │
|
||||
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
||||
│ └─ BoxRuntimeConnector │
|
||||
│ ├─ 心跳 loop (20s ping) │
|
||||
@@ -41,7 +42,7 @@
|
||||
│ │ Action RPC (stdio 或 WebSocket) │
|
||||
│ │
|
||||
│ SkillManager (skill_mgr) │
|
||||
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
||||
│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -59,10 +60,8 @@
|
||||
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
||||
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
||||
│ │
|
||||
│ BoxSkillStore │
|
||||
│ ├─ list / get / create / update / delete │
|
||||
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
|
||||
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
|
||||
│ Generic mount admission │
|
||||
│ └─ allow-list + read-only + normalized target validation │
|
||||
│ │
|
||||
│ aiohttp 单端口服务 (默认 :5410): │
|
||||
│ /rpc/ws — Action RPC │
|
||||
@@ -85,7 +84,7 @@
|
||||
**核心设计原则**:
|
||||
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
||||
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
||||
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md))
|
||||
- Skill 仅是 Core 组装 mount 的业务来源;Box 与默认 exec / MCP Server 只共享通用 session 和 mount 机制(详见 [box-session-scope.md](./box-session-scope.md))
|
||||
|
||||
---
|
||||
|
||||
@@ -93,7 +92,7 @@
|
||||
|
||||
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
||||
|
||||
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
|
||||
应用层门面,协调 Profile、安全校验、配额、连接、Core 生成的只读挂载与 Session 模板:
|
||||
|
||||
主要公开方法(按定义顺序):
|
||||
|
||||
@@ -105,7 +104,7 @@ BoxService
|
||||
├─ available (property) 连接状态
|
||||
│
|
||||
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
||||
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
|
||||
├─ execute_tool(..., read_only_mounts=...) 接收 Core 组装的通用只读挂载
|
||||
│
|
||||
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
||||
│ ├─ _apply_profile / build_spec
|
||||
@@ -122,12 +121,6 @@ BoxService
|
||||
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
||||
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
|
||||
│
|
||||
├─ list_skills() / get_skill(name) Skill 元数据
|
||||
├─ create_skill / update_skill / delete_skill Skill CRUD
|
||||
├─ scan_skill_directory(path) 扫描目录
|
||||
├─ list_skill_files / read_skill_file / write_skill_file
|
||||
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
|
||||
│
|
||||
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
|
||||
├─ get_status() / get_sessions() / get_recent_errors()
|
||||
└─ get_system_guidance() LLM 系统提示
|
||||
@@ -137,7 +130,7 @@ BoxService
|
||||
|
||||
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
||||
|
||||
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
|
||||
**Skill 挂载合并**: native loader 调用 `skill.build_execution_mounts()`,把当前 pipeline-bound 的所有 skill 的 `package_root` 转成普通只读 mount,再通过 `BoxService.execute_tool(..., read_only_mounts=...)` 交给 Box,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径;BoxService 和 Box Runtime 都不知道这些 mount 来自 Skill。
|
||||
|
||||
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
||||
|
||||
@@ -171,11 +164,10 @@ BoxService
|
||||
```
|
||||
SkillManager
|
||||
├─ initialize() 调用 reload_skills()
|
||||
├─ reload_skills() 先从 Box runtime list_skills(),
|
||||
│ 不可用则回落 data/skills/ 扫描
|
||||
├─ refresh_skill_from_disk() 单 skill 重新加载
|
||||
├─ reload_skills() 从 Core SkillRepository 加载
|
||||
├─ refresh_skill_from_disk() 检查单 skill 的缓存状态
|
||||
├─ get_skill_by_name(name)
|
||||
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径
|
||||
└─ build_skill_aware_prompt_addition() 生成渐进披露索引
|
||||
```
|
||||
|
||||
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
|
||||
@@ -295,7 +287,7 @@ start_managed_process(session, spec):
|
||||
|
||||
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
|
||||
|
||||
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等
|
||||
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 `INIT`、exec、session、managed-process 与状态等通用 action
|
||||
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
|
||||
|
||||
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。
|
||||
@@ -304,7 +296,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
|
||||
|
||||
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
|
||||
|
||||
- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown)
|
||||
- 方法对应 exec / session / managed-process / status / shutdown 等通用 RPC action,不暴露 Skill CRUD
|
||||
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
|
||||
- `execute()` timeout = 300s,其他默认 15s
|
||||
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
||||
@@ -343,23 +335,29 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
|
||||
|
||||
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
|
||||
|
||||
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行)
|
||||
### 3.7 SkillStore (`langbot_plugin.skill_store`)
|
||||
|
||||
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime:
|
||||
Skill 包存储最初位于 Box Runtime;issue #2410 将通用实现抽到 Plugin SDK 顶层,由 Core 独占存储和 revision 语义:
|
||||
|
||||
```
|
||||
BoxSkillStore
|
||||
SkillStore
|
||||
├─ list_skills() / get_skill(name)
|
||||
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
||||
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
||||
├─ list_skill_files(name, path) 浏览 skill 内文件树
|
||||
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
|
||||
├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源
|
||||
├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源
|
||||
├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口
|
||||
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
||||
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
||||
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
|
||||
```
|
||||
|
||||
GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。
|
||||
GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件位于独立的 `skills.root`,执行时由 Core 组装成通用只读 `BoxMountSpec` 并挂载到 `/workspace/.skills/`。Box 的模型、客户端和 Runtime 不包含 `skill_name`、Skill CRUD、revision 或 `SKILL.md` 语义。Core 与 Box Runtime SDK 按同一发布单元同步升级;Box 不保留旧 Skill RPC,也会拒绝旧的 Skill-aware payload 字段。
|
||||
|
||||
仍保留的兼容仅用于已有数据的在线升级,不用于混版本协议:
|
||||
|
||||
1. Core 暂时读取 `box.local.skills_root`,兼容尚未生成 `skills.root` 的持久化配置。
|
||||
2. 新安装默认暂时沿用历史 `./data/box/skills`,避免升级时搬迁已安装 Skill;下一大版本切换到 `./data/skills`。
|
||||
|
||||
### 3.8 Security (`box/security.py`, 52 行)
|
||||
|
||||
@@ -463,7 +461,7 @@ BuildAppStage.run(ap)
|
||||
├─ ap.tool_mgr = tool_mgr
|
||||
│
|
||||
├─ ... (platform, pipeline) ...
|
||||
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
|
||||
├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表)
|
||||
└─ ... (RAG, HTTP, plugins) ...
|
||||
```
|
||||
|
||||
@@ -480,7 +478,7 @@ except Exception as e:
|
||||
logger.warning(f"Box runtime unavailable: {e}")
|
||||
```
|
||||
|
||||
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
||||
**静默降级**: Box 初始化失败不会阻止应用启动。6 个 native tool、`register_skill` 和 MCP-in-Box 工具不暴露给 LLM;`activate` 与 Skill 只读资源工具继续由 Core 提供。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
||||
|
||||
### 5.3 销毁流程
|
||||
|
||||
@@ -504,13 +502,16 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的
|
||||
### config.yaml (重构后)
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
root: './data/box/skills' # Core-owned;Box 关闭时仍可管理/读取
|
||||
|
||||
box:
|
||||
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
||||
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
||||
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
||||
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
|
||||
# - Agent 从 sandbox 注册 skill 的能力不可用
|
||||
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
||||
# - skill 列表/读取保持只读可用
|
||||
# - skill 管理、激活和只读资源保持可用
|
||||
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||
# 由 box.backend / BOX__BACKEND 选择后端
|
||||
@@ -522,7 +523,6 @@ box:
|
||||
image: '' # 覆盖 profile 默认 image
|
||||
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
||||
default_workspace: '' # 默认 '<host_root>/default'
|
||||
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
||||
allowed_mount_roots: # 默认 ['<host_root>']
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
@@ -561,16 +561,17 @@ volumes:
|
||||
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
||||
|---|---|---|
|
||||
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
|
||||
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** |
|
||||
| `activate` / Skill resource 工具 | 暴露给 LLM | 暴露给 LLM |
|
||||
| `register_skill` 工具 | 暴露给 LLM | **不暴露**;直接调用返回 `sandbox_unavailable` |
|
||||
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
|
||||
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
||||
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
|
||||
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
|
||||
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Core SkillRepository | 走 Core SkillRepository |
|
||||
| Skill 创建/编辑/安装/写文件 | 走 Core SkillRepository | 走 Core SkillRepository |
|
||||
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
||||
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
|
||||
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 |
|
||||
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
|
||||
|
||||
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。
|
||||
> Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层。默认 `skills.root` 保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill;旧 `box.local.skills_root` 仅作为在线升级 fallback,并将在下一大版本删除。Box 只消费 Core 下发的通用只读 mount。
|
||||
|
||||
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
### S5. 挂载校验缺口 — Med-High
|
||||
|
||||
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
||||
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
||||
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
||||
- **现状**: grant-enforced 模式已经由 Core 与 Runtime 双重校验通用只读 mount(绝对路径 allow-list、存在性、只读模式、规范化且位于 `/workspace` 下的目标);它不再有 Skill 特例。遗留风险仅在 admission-disabled 的低信任直连场景:通用 `extra_mounts` 仍未统一套用 grant-enforced 白名单。
|
||||
- **要求**: admission-disabled 的外部控制面也复用同一套通用 mount 校验;SDK 黑名单加入 `/`(或全面改白名单)。
|
||||
|
||||
### S6. 容器加固缺失 — Med
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ has shipped the design largely as written:
|
||||
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
||||
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
||||
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
||||
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
|
||||
| `skill.build_execution_mounts(ap, query)` | ✅ Shipped | Core composes read-only packages; Box receives generic mounts |
|
||||
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
||||
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
||||
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
||||
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
||||
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
||||
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
|
||||
| BoxService session / mount contract | 良好 | `resolve_box_session_id` + generic `read_only_mounts`; Skill mount composition is covered in the Core loader |
|
||||
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
||||
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
||||
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
# 模型思考控制设计方案
|
||||
|
||||
> 日期:2026-07-31
|
||||
> 状态:Phase 1 已审核并实现
|
||||
> 范围:LangBot 主仓库的模型配置、LiteLLM 请求层、Local Agent、Web 管理面板、监控与测试
|
||||
|
||||
## 1. 结论
|
||||
|
||||
建议为 LangBot 增加一套与厂商参数解耦的“思考策略”模型,并明确区分三个概念:
|
||||
|
||||
1. **思考能力**:模型是否支持思考,以及支持开关、档位还是 token 预算。
|
||||
2. **思考策略**:一次请求选择厂商默认、关闭、开启或指定思考档位。
|
||||
3. **思考展示**:是否把模型返回的思考内容展示给最终用户。
|
||||
|
||||
现有 `remove-think` 只属于第 3 类。它会过滤输出,但不会阻止模型思考,也不会降低思考 token、费用或延迟。新能力不应复用或改写这个字段。
|
||||
|
||||
推荐实现原则:
|
||||
|
||||
- 默认值为 `provider_default`,不向上游增加任何新参数,现有模型行为完全不变。
|
||||
- 用户显式选择的策略必须被准确执行;无法准确执行时返回明确错误,不静默降级。
|
||||
- LangBot 内部只保存统一策略,Provider 请求层负责翻译成各厂商参数。
|
||||
- `extra_args` 保留为高级逃生口,但不能成为主 UI 的思考配置方式。
|
||||
- 模型页只管理并展示能力;可写策略归属于 Local Agent 流水线,同一模型可在不同业务中使用不同思考量。
|
||||
- 原始 reasoning 数据与展示文本分开保存,保证多轮对话、工具调用和签名字段不丢失。
|
||||
|
||||
## 2. 调研结论
|
||||
|
||||
### 2.1 可验证资料
|
||||
|
||||
本次结论基于以下可验证来源:
|
||||
|
||||
- OpenAI 官方 Reasoning Guide:`reasoning.effort` 的可选值由模型决定,可包括 `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`;低档位偏向低延迟和低 token,高档位偏向质量。
|
||||
- https://developers.openai.com/api/docs/guides/reasoning#reasoning-effort
|
||||
- LangBot 锁定的 LiteLLM `1.88.1` 实现。`uv.lock` 已锁定该版本,本地缓存中的适配代码可以确认 LangBot 实际依赖所支持的翻译行为。
|
||||
- LangBot 当前实现:模型级 `extra_args` 会在 `LiteLLMRequester._build_completion_args()` 中直接合并到 `acompletion()` 参数。
|
||||
|
||||
Anthropic、Google 和 LiteLLM 的官方文档域名在本次环境中被浏览器策略禁止访问,因此下表中这些厂商的结论以 LiteLLM `1.88.1` 实际适配代码为准。实施前应再用对应厂商官方文档做一次参数范围核验,尤其是模型代际和允许值。
|
||||
|
||||
### 2.2 厂商差异矩阵
|
||||
|
||||
| Provider / 生态 | 可控制能力 | LiteLLM 1.88.1 统一入口 | 关键限制 | 建议支持级别 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| OpenAI | 思考档位,部分模型支持 `none` | `reasoning_effort` | 每个模型支持的档位不同,不能把 `none` 当成通用能力 | 首批完整支持 |
|
||||
| Anthropic | 旧模型使用 extended thinking + token budget;新模型可用 adaptive thinking + effort | `reasoning_effort` 或 `thinking` | `none` 表示不发送 thinking;新旧模型的映射不同 | 首批完整支持 |
|
||||
| Gemini | 2.x 主要映射为 `thinkingBudget`;3.x 主要映射为 `thinkingLevel` | `reasoning_effort` 或 `thinking` | Gemini 3 的 `none` 可能只能降到最低档,不能保证真正关闭 | 首批支持,但严格限制关闭语义 |
|
||||
| DeepSeek | 开启/关闭;当前适配不支持预算档位 | `thinking={type: enabled}`;非 `none` effort 会映射成开启 | 多轮思考模式要求回传 `reasoning_content` | 首批开关支持 |
|
||||
| xAI | 思考档位 | `reasoning_effort` | 仅 reasoning-capable 模型接受 | 首批完整支持 |
|
||||
| Ollama | `think` 布尔值;部分模型接受 low/medium/high | `reasoning_effort` | 非 gpt-oss 模型的档位可能退化为布尔开关 | 首批支持,按模型能力裁剪 UI |
|
||||
| OpenRouter | 聚合多厂商的 reasoning 参数 | `reasoning_effort`、`thinking` | 实际能力由路由后的模型决定 | 首批支持,能力未知时要求测试 |
|
||||
| Volcengine / Doubao | `thinking.type` 支持 enabled/disabled/auto | LiteLLM `volcengine` 适配器支持 `thinking` | LangBot 当前 manifest 使用 `openai`,不会进入该适配器 | 第二批,先修正路由并回归 |
|
||||
| Bailian / Qwen | 厂商兼容接口有独立思考开关/预算 | LiteLLM `dashscope` 适配器目前未提供统一 reasoning 映射 | LangBot 当前 manifest 使用 `openai`,只能通过高级参数透传 | 第二批,实施前核对官方字段 |
|
||||
| 其他 OpenAI-compatible 网关 | 取决于网关 | 尝试标准 `reasoning_effort` | 不能仅凭模型名推断完整能力 | 保守支持,默认不自动开启 |
|
||||
|
||||
### 2.3 对 LangBot 的直接含义
|
||||
|
||||
不能把这个功能实现成单一 `enable_thinking: bool`,原因如下:
|
||||
|
||||
- 有的模型只有开关,有的模型只有档位,有的模型允许精确 token 预算。
|
||||
- 有的模型本身始终推理,只能降低思考量,无法真正关闭。
|
||||
- 同一个通用档位在不同厂商会映射成不同的实际预算。
|
||||
- 聚合网关和自定义 OpenAI-compatible 服务无法可靠地通过模型名识别能力。
|
||||
- “不展示思考内容”不等于“关闭思考”。
|
||||
|
||||
## 3. 当前项目现状
|
||||
|
||||
### 3.1 已有能力
|
||||
|
||||
- `LLMModel.extra_args` 是 JSON 字段,Web 端已有通用高级参数编辑器。
|
||||
- `LiteLLMRequester` 会按“模型级 `extra_args`,再调用级 `extra_args`”的顺序合并参数。
|
||||
- LiteLLM 已统一处理多个 Provider 的 `reasoning_effort`、`thinking` 和返回的 `reasoning_content`。
|
||||
- `LocalAgentRunner` 的非流式、流式、工具调用和 fallback 路径都经过 `RuntimeProvider.invoke_llm*()`。
|
||||
- `remove-think` 已能控制 `<think>` 或独立 reasoning 内容是否进入展示文本。
|
||||
- Gemini 工具调用所需的 `provider_specific_fields` / thought signature 已有保留逻辑和单元测试。
|
||||
|
||||
### 3.2 现有缺口
|
||||
|
||||
- 管理员只能手写 `extra_args`,没有统一语义、能力提示和校验。
|
||||
- `remove-think` 名称容易被误解为关闭模型思考。
|
||||
- 模型扫描只识别 `vision` 和 `func_call`,没有 reasoning 能力。
|
||||
- 当前返回处理会把 `reasoning_content` 拼进 `<think>` 文本后删除原字段,可能损失多轮思考所需的结构化数据。
|
||||
- DeepSeek 思考模式需要在后续轮次回传 `reasoning_content`,当前链路不能保证完整保留。
|
||||
- Pipeline 只能选择模型,不能针对业务覆盖模型的思考策略。
|
||||
- 监控只记录总输入/输出 token,没有单独展示 reasoning token。
|
||||
- 部分 Provider manifest 仍声明为通用 `openai`,导致 LiteLLM 的厂商专用翻译器不会生效。
|
||||
|
||||
### 3.3 预计改动地图
|
||||
|
||||
| 层 | 主要文件 | 责任 |
|
||||
| --- | --- | --- |
|
||||
| 持久化 | `src/langbot/pkg/entity/persistence/model.py`、`src/langbot/pkg/persistence/alembic/versions/` | 新增 `reasoning_config` JSON 列和 Alembic 迁移 |
|
||||
| 模型服务 | `src/langbot/pkg/api/http/service/model.py` | CRUD 校验、冲突检测、测试模型时使用统一策略 |
|
||||
| HTTP 控制器 | `src/langbot/pkg/api/http/controller/groups/provider/models.py` | 继续复用现有模型路由,不新增平行 API |
|
||||
| 模型管理 | `src/langbot/pkg/provider/modelmgr/modelmgr.py` | 临时模型、数据库模型与扫描结果加载新字段 |
|
||||
| 请求抽象 | `src/langbot/pkg/provider/modelmgr/requester.py` | 定义能力查询和 reasoning 参数构建接口 |
|
||||
| LiteLLM 适配 | `src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py` | 能力识别、策略翻译、参数合并、reasoning 返回保留 |
|
||||
| Provider manifest | `src/langbot/pkg/provider/modelmgr/requesters/*.yaml` | 必要时修正 Provider 路由;相关变更放到独立阶段 |
|
||||
| Agent 调用 | `src/langbot/pkg/provider/runners/localagent.py` | 所有非流式、流式、工具调用、fallback 路径传递统一策略 |
|
||||
| Pipeline 元数据 | `src/langbot/templates/metadata/pipeline/ai.yaml` | 第二阶段加入 Pipeline 级覆盖 |
|
||||
| 输出配置 | `src/langbot/templates/metadata/pipeline/output.yaml` | 保留键名,澄清 `remove-think` 只控制展示 |
|
||||
| Web 类型/API | `web/src/app/infra/entities/api/index.ts`、`web/src/app/infra/http/BackendClient.ts` | 增加配置与能力响应类型 |
|
||||
| 模型 UI | `web/src/app/home/components/models-dialog/` | 能力标记、策略控件、校验、模型测试 |
|
||||
| i18n | `web/src/i18n/locales/` | 至少补齐英文、简体中文及项目已有覆盖语言 |
|
||||
| 测试 | `tests/unit_tests/provider/`、`web/tests/` | 翻译、服务、流式 round-trip、前端状态测试 |
|
||||
|
||||
Phase 1 不修改 `langbot-plugin-sdk` 的公共实体或运行时协议。现有 `provider_message.Message.provider_specific_fields` 已可承载 Provider 原始 reasoning 数据;只有后续要把 reasoning 升级为跨插件公开实体时,才需要跨仓库 SDK 变更。
|
||||
|
||||
## 4. 领域模型
|
||||
|
||||
### 4.1 统一策略
|
||||
|
||||
新增 `ReasoningConfig`,保存于 LLM 模型,Pipeline 可提供同结构覆盖。产品层只暴露一个离散档位:
|
||||
|
||||
```json
|
||||
{
|
||||
"level": "provider_default"
|
||||
}
|
||||
```
|
||||
|
||||
字段定义:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `level` | `provider_default \| disabled \| enabled \| minimal \| low \| medium \| high \| xhigh \| max` | 同时表达开关和思考强度 |
|
||||
|
||||
校验规则:
|
||||
|
||||
- `provider_default`:不发送任何 reasoning 参数,保持厂商和模型默认行为。
|
||||
- `disabled`:明确关闭;仅当模型可真正关闭时允许保存/运行。
|
||||
- `enabled`:明确开启,但由 Provider 决定具体强度,适用于只有开关的模型。
|
||||
- `minimal` 到 `max`:明确开启,并指定强度;仅允许选择模型实际支持的档位。
|
||||
- 厂商的 `auto` 统一映射为 `provider_default`,不再增加一个重复状态。
|
||||
- 精确 token 预算不进入主数据结构。少数需要预算的场景继续通过高级参数配置,并由模型测试接口校验。
|
||||
|
||||
### 4.2 能力描述
|
||||
|
||||
沿用现有 `LLMModel.abilities`,新增 `reasoning` 能力标记。同时由后端在 API 返回中计算只读的 `reasoning_capabilities`:
|
||||
|
||||
```json
|
||||
{
|
||||
"supported": true,
|
||||
"controls": ["toggle", "effort"],
|
||||
"efforts": ["none", "low", "medium", "high"],
|
||||
"can_disable": true,
|
||||
"source": "litellm"
|
||||
}
|
||||
```
|
||||
|
||||
设计约束:
|
||||
|
||||
- `abilities` 仍是用户可编辑的粗粒度能力,符合现有 `vision`、`func_call` 模式。
|
||||
- `reasoning_capabilities` 不持久化,优先从 LiteLLM 模型元数据计算,避免模型升级后数据库残留过期能力。
|
||||
- 无法识别的自定义模型返回 `supported: null`、`source: unknown`,不猜测。
|
||||
- 用户可手动添加 `reasoning` ability,但未知能力模型必须先通过“测试模型”验证显式策略。
|
||||
- UI 只展示后端声明可用的控件;未知模型保留 Provider Default 和高级参数入口。
|
||||
|
||||
### 4.3 持久化
|
||||
|
||||
在 `llm_models` 表新增 JSON 列:
|
||||
|
||||
```text
|
||||
reasoning_config JSON NOT NULL DEFAULT {"level":"provider_default"}
|
||||
```
|
||||
|
||||
使用 Alembic 新迁移,不修改冻结的 legacy migration。
|
||||
|
||||
该列作为已实现版本的兼容字段保留;新的模型页不再提供写入口,Local Agent 请求以流水线中按模型 UUID 保存的策略为准。
|
||||
|
||||
不建议把内部策略塞进 `extra_args`,原因是当前 `extra_args` 会原样发送给 LiteLLM;使用保留键会让内部元数据泄漏到上游,并使高级参数与产品配置难以区分。
|
||||
|
||||
## 5. 配置优先级与请求流程
|
||||
|
||||
### 5.1 优先级
|
||||
|
||||
```text
|
||||
Pipeline 当前候选模型策略
|
||||
↓ 缺少配置时固定为 provider_default
|
||||
Provider / 模型默认行为
|
||||
```
|
||||
|
||||
请求参数合并顺序:
|
||||
|
||||
```text
|
||||
基础参数
|
||||
-> 模型 extra_args
|
||||
-> 调用级 extra_args
|
||||
-> 统一 reasoning 策略翻译结果(最后应用)
|
||||
```
|
||||
|
||||
统一策略最后应用,可以确保流水线行为不受模型页历史设置影响。为了避免用户困惑,保存和测试时要检测 `extra_args` 中的冲突字段;当 `level != provider_default` 时,发现以下字段应直接报错:
|
||||
|
||||
- `reasoning_effort`
|
||||
- `thinking`
|
||||
- `reasoning`
|
||||
- `extra_body` 内已知的 `thinking`、`enable_thinking`、`thinking_budget` 等字段
|
||||
|
||||
当 `level == provider_default` 时继续允许这些高级参数,保证旧配置兼容。
|
||||
|
||||
### 5.2 翻译层
|
||||
|
||||
在 `pkg/provider/modelmgr/` 内新增独立的 reasoning 规范化模块,职责是:
|
||||
|
||||
1. 读取当前流水线候选模型的请求级策略。
|
||||
2. 查询 `ProviderAPIRequester.get_reasoning_capabilities(model)`。
|
||||
3. 严格校验策略是否可以准确执行。
|
||||
4. 生成 LiteLLM 参数,不直接发 HTTP。
|
||||
5. 返回可观测的“最终生效策略”供日志和测试使用。
|
||||
|
||||
建议接口:
|
||||
|
||||
```python
|
||||
class ProviderAPIRequester:
|
||||
def get_reasoning_capabilities(self, model: RuntimeLLMModel) -> ReasoningCapabilities: ...
|
||||
|
||||
def build_reasoning_args(
|
||||
self,
|
||||
model: RuntimeLLMModel,
|
||||
config: ReasoningConfig,
|
||||
) -> dict[str, Any]: ...
|
||||
```
|
||||
|
||||
LiteLLMRequester 默认优先生成统一参数:
|
||||
|
||||
- 强度档位:`reasoning_effort=<level>`
|
||||
- 仅开启:`thinking={"type":"enabled"}` 或 Provider 等价参数
|
||||
- 关闭:优先 `reasoning_effort="none"`
|
||||
- 高级参数中的精确预算:`thinking={"type":"enabled","budget_tokens":N}`
|
||||
|
||||
Provider 特例只放在 requester 翻译层,不进入 Pipeline 或平台适配器。
|
||||
|
||||
### 5.3 Provider 特例
|
||||
|
||||
- **Gemini 3**:如果 LiteLLM 能力表不能确认真正关闭,`disabled` 必须报“不支持关闭,可选择 Provider Default 或最低档”,不能把 `none` 静默映射成 low/minimal。
|
||||
- **DeepSeek**:所有非 `none` 档位最终都只是开启。能力 API 只返回 `toggle`,UI 不显示档位;多轮必须保存并回传 `reasoning_content`。
|
||||
- **Ollama**:仅对明确支持等级的模型展示 effort;其他模型只展示开关。
|
||||
- **OpenRouter**:以路由后的模型能力为准。模型未知时允许 Provider Default,显式策略必须通过测试接口。
|
||||
- **Volcengine**:使用 `thinking.type=enabled/disabled/auto`。应先让该 requester 进入 LiteLLM `volcengine` 适配器,或增加等价的明确翻译,不能依赖模型名。
|
||||
- **Bailian/Qwen**:作为第二批 Provider 专用翻译。实施前核对官方字段、模型范围、预算上下限和流式返回结构,不凭经验写接口。
|
||||
|
||||
## 6. 返回数据与思考展示
|
||||
|
||||
### 6.1 保留原始 reasoning
|
||||
|
||||
当前 `LiteLLMRequester` 会读取 `reasoning_content`,将其拼接成 `<think>` 文本,再删除原字段。建议改为:
|
||||
|
||||
```text
|
||||
上游 reasoning_content
|
||||
├─ 原样保存在 Message.provider_specific_fields.reasoning_content
|
||||
└─ 根据 remove-think 决定是否渲染为 <think>...</think>
|
||||
```
|
||||
|
||||
流式路径需要在 accumulator 中分别累计 `content` 与 `reasoning_content`,最终消息必须携带结构化 reasoning。不能只依赖已经渲染的 `<think>` 文本反向解析。
|
||||
|
||||
这样可以同时满足:
|
||||
|
||||
- `remove-think=true` 时用户看不到思考内容,但多轮协议仍能回传必要数据。
|
||||
- `remove-think=false` 时保持当前用户体验。
|
||||
- DeepSeek 多轮 thinking 不丢上下文。
|
||||
- Gemini thought signature、Anthropic thinking block 等 Provider 字段可以继续按结构化方式 round-trip。
|
||||
|
||||
### 6.2 现有字段处理
|
||||
|
||||
保留数据库和 Pipeline 配置键 `remove-think`,避免破坏兼容。Web 文案改为更准确的:
|
||||
|
||||
- 中文:`向用户展示思考过程`
|
||||
- 英文:`Show reasoning process`
|
||||
|
||||
UI 使用正向开关,保存时转换回 `remove-think = !showReasoning`。文案必须强调它只影响展示,不影响模型是否思考、token 或费用。
|
||||
|
||||
## 7. Web 管理面板
|
||||
|
||||
### 7.1 模型编辑
|
||||
|
||||
模型页只承担能力管理和只读展示:
|
||||
|
||||
1. `Reasoning` ability 复选框与 Vision、Function Calling 并列,供无法自动识别的自定义模型手动声明能力。
|
||||
2. 模型卡片使用简短图标或 badge 标识 reasoning 能力。
|
||||
3. 模型页不提供可写思考挡位,避免模型默认值与流水线策略形成两个控制源。
|
||||
|
||||
### 7.2 Local Agent 流水线策略
|
||||
|
||||
在 Local Agent 的主模型和每一个 fallback 模型下分别显示紧凑离散滑杆:
|
||||
|
||||
1. `Provider 默认` 始终为首个选项;选择它时不向上游增加任何思考参数。
|
||||
2. 完整档位顺序为:`Provider 默认 / 关闭 / 开启 / 最低 / 低 / 中 / 高 / 极高 / 最大`。
|
||||
3. 前端只渲染后端为该模型返回的可用档位;仅开关模型显示 `Provider 默认 / 关闭 / 开启`。
|
||||
4. 模型不能真正关闭时不提供 `关闭`;能力未知时只显示不可调的 `Provider 默认`。
|
||||
5. 主模型和 fallback 分别保存策略,切换候选模型时不会把一个模型的挡位错误应用到另一个模型。
|
||||
6. Dify、Coze、Langflow、n8n 等外部 Runner 不显示该控件,因为 LangBot 不直接发起其内部模型请求。
|
||||
|
||||
流水线配置保持旧格式兼容,并在模型选择对象中增加按 UUID 保存的映射:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"primary": "primary-model-uuid",
|
||||
"fallbacks": ["fallback-model-uuid"],
|
||||
"reasoning": {
|
||||
"primary-model-uuid": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`provider_default` 不写入映射;缺少 `reasoning` 的旧流水线天然等价于全部使用 Provider 默认。
|
||||
|
||||
滑杆交互要求:轨道使用现有主色和中性灰,不使用渐变;当前档位同时显示文字;支持键盘方向键和正确的 ARIA value text;窄屏下不溢出。
|
||||
|
||||
### 7.3 i18n
|
||||
|
||||
新增文案至少覆盖 `en_US`、`zh_Hans`;`ja_JP` 在模型面板现有同类字段已覆盖时同步补齐。不要把厂商参数名直接作为用户文案。
|
||||
|
||||
## 8. API、MCP 与 Skill
|
||||
|
||||
### 8.1 HTTP API
|
||||
|
||||
模型 CRUD 增加:
|
||||
|
||||
- 请求字段:`reasoning_config`
|
||||
- 响应字段:`reasoning_config`
|
||||
- 只读字段:`reasoning_capabilities`
|
||||
|
||||
模型测试接口必须使用与真实请求完全相同的规范化和翻译逻辑,并在失败时返回可操作错误,例如:
|
||||
|
||||
```text
|
||||
Model gemini-3-... cannot disable reasoning.
|
||||
Supported controls: effort=[low, medium, high].
|
||||
```
|
||||
|
||||
可选增加只读调试信息,仅在测试接口返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"effective_reasoning": {
|
||||
"level": "low",
|
||||
"translated_keys": ["reasoning_effort"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不得返回 API key、完整请求正文或原始思考内容。
|
||||
|
||||
### 8.2 MCP 与技能
|
||||
|
||||
当前 MCP 仅列出模型 Provider,没有完整模型 CRUD 工具。如果本次不新增 agent-accessible HTTP 操作,则无需强行新增 MCP 工具。
|
||||
|
||||
如果后续让 Agent 修改模型思考策略,则必须同一提交更新:
|
||||
|
||||
- `src/langbot/pkg/api/mcp/server.py`
|
||||
- 对应的 `skills/` 文档
|
||||
- 参数 schema 和安全说明
|
||||
|
||||
## 9. 监控与可观测性
|
||||
|
||||
控制思考量后,管理员需要判断质量、延迟和成本是否值得。建议第二阶段增加:
|
||||
|
||||
- `reasoning_tokens`:从 `completion_tokens_details.reasoning_tokens` 或 Provider 等价字段提取。
|
||||
- `effective_reasoning_level`:记录规范化后的生效档位,不记录原始思考内容。
|
||||
- 模型监控页展示输入 token、可见输出 token、reasoning token、总延迟。
|
||||
- Provider 不返回细分 token 时显示未知,不推算。
|
||||
|
||||
安全要求:日志、监控、debug API 默认都不得记录 reasoning 原文。思考内容可能包含敏感信息或系统提示,不应因为新增配置而扩大持久化范围。
|
||||
|
||||
## 10. 兼容与迁移
|
||||
|
||||
### 10.1 数据迁移
|
||||
|
||||
- 所有现有 LLM 记录迁移为 `{"level":"provider_default"}`。
|
||||
- 不自动解析或迁移现有 `extra_args` 中的 reasoning 参数,避免误判嵌套结构和 Provider 语义。
|
||||
- UI 检测到旧 `extra_args` reasoning 字段时显示“由高级参数控制”,统一策略保持 Provider Default。
|
||||
- 用户主动改成统一策略时,要求先移除冲突高级参数。
|
||||
|
||||
### 10.2 运行时兼容
|
||||
|
||||
- `provider_default` 不产生任何新增请求参数。
|
||||
- 不改变现有 `remove-think` 的存储键和默认值。
|
||||
- 不改变已有 Provider 的 `litellm_provider`,除非该 Provider 在专项回归后单独切换。
|
||||
- `drop_params` 不能用于掩盖显式 reasoning 配置错误;显式策略被丢弃应视为失败。
|
||||
- 自托管和 toB 环境中的自定义兼容接口保持可用,未知能力不阻止 Provider Default 请求。
|
||||
|
||||
## 11. 实施拆分
|
||||
|
||||
### Phase 1:统一基础设施与主流 Provider
|
||||
|
||||
- Alembic 增加 `llm_models.reasoning_config`。
|
||||
- Backend 模型实体、CRUD、测试接口支持统一配置。
|
||||
- LiteLLMRequester 增加能力查询、严格校验和参数翻译。
|
||||
- 支持 OpenAI、Anthropic、Gemini、DeepSeek、xAI、Ollama、OpenRouter 的已验证 LiteLLM 路径。
|
||||
- 修复结构化 reasoning 的非流式/流式保留。
|
||||
- 模型面板增加 reasoning ability 与只读能力标识。
|
||||
- Local Agent 主模型和每个 fallback 增加独立的请求级策略。
|
||||
|
||||
### Phase 2:国内 Provider
|
||||
|
||||
- 专项核对并支持 Volcengine/Doubao、Bailian/Qwen。
|
||||
- 对相关 requester 的 `litellm_provider` 变更做独立回归,避免把 reasoning 功能和通用请求行为回归混在一起。
|
||||
- 补齐扫描结果中的 reasoning capability。
|
||||
|
||||
### Phase 3:监控与评估
|
||||
|
||||
- 持久化 reasoning token 和生效策略。
|
||||
- 监控页增加 reasoning 成本/延迟指标。
|
||||
- 建立不同 effort 的离线质量、首 token 延迟、总耗时和 token 对比基线。
|
||||
|
||||
## 12. 测试方案
|
||||
|
||||
### 12.1 单元测试
|
||||
|
||||
- `ReasoningConfig` 所有合法/非法组合。
|
||||
- `provider_default` 不产生任何新增参数。
|
||||
- 显式配置覆盖模型/调用 `extra_args` 的顺序。
|
||||
- reasoning 配置与高级参数冲突时拒绝。
|
||||
- OpenAI 档位原样映射。
|
||||
- Anthropic 档位映射,以及高级参数预算兼容。
|
||||
- Gemini 2 budget、Gemini 3 level,以及不支持真正关闭时拒绝。
|
||||
- DeepSeek 只显示/接受 toggle,非 `none` effort 不伪装成不同档位。
|
||||
- Ollama 布尔与分级模型差异。
|
||||
- Volcengine enabled/disabled/auto 翻译。
|
||||
- 未知 Provider 只允许 Provider Default,或在显式测试后使用标准参数。
|
||||
- 非流式 `reasoning_content` 保存到 `provider_specific_fields`。
|
||||
- 流式 reasoning 分片累计后仍能 round-trip。
|
||||
- Gemini thought signature 和工具调用现有测试不能回归。
|
||||
|
||||
### 12.2 服务与持久化测试
|
||||
|
||||
- 新建、读取、更新模型的 `reasoning_config`。
|
||||
- Alembic 从当前 head 升级后默认值正确。
|
||||
- 模型测试接口与真实 Local Agent 使用同一翻译函数。
|
||||
- 旧模型、旧 `extra_args` 和 `remove-think` 行为不变。
|
||||
|
||||
### 12.3 前端测试
|
||||
|
||||
- 能力不同的模型显示正确控件。
|
||||
- 离散滑杆只能停在后端返回的可用档位。
|
||||
- 当前档位文字、键盘操作和 ARIA value text 正确。
|
||||
- 仅开关模型、不可关闭模型、完整档位模型分别显示正确刻度。
|
||||
- fallback 能力不兼容时阻止保存并给出明确提示。
|
||||
- 中英文文案完整,移动端 Popover 不溢出。
|
||||
|
||||
### 12.4 Provider 冒烟测试
|
||||
|
||||
至少选取以下真实或可控 mock:
|
||||
|
||||
- 一个支持 `none` 的 OpenAI reasoning 模型。
|
||||
- 一个不支持 `none` 的 reasoning 模型。
|
||||
- 一个 Anthropic adaptive thinking 模型。
|
||||
- 一个 Gemini 2.x 与一个 Gemini 3.x 模型。
|
||||
- 一个 DeepSeek hybrid thinking 模型,执行两轮含工具调用对话。
|
||||
- 一个 Ollama 本地 reasoning 模型。
|
||||
- 一个 OpenAI-compatible 自定义网关,验证 Provider Default 完全不变。
|
||||
|
||||
每个模型比较 Provider Default、最低档、中档、高档或关闭,记录成功率、首 token 延迟、总耗时、总 token 和 reasoning token(若可用)。
|
||||
|
||||
## 13. 风险与控制
|
||||
|
||||
| 风险 | 影响 | 控制措施 |
|
||||
| --- | --- | --- |
|
||||
| 将“最低思考”误当成“关闭” | 用户以为节省了成本,实际仍在推理 | `can_disable` 严格校验,不静默降级 |
|
||||
| 模型能力表过期 | 新模型无法配置或旧模型报错 | 能力未知时保守;允许测试;升级 LiteLLM 时回归 |
|
||||
| 高 effort 导致延迟/费用陡增 | 用户体验和预算风险 | 默认 Provider Default;UI 提示;后续监控 reasoning token |
|
||||
| `extra_args` 与统一配置冲突 | 实际生效值不可预测 | 保存/测试时拒绝冲突;统一策略最后应用 |
|
||||
| reasoning 原文进入日志 | 敏感信息泄露 | 不记录原文,只记录策略和 token |
|
||||
| 多轮 reasoning 丢失 | 工具调用或后续轮次失败/降质 | 结构化保存并 round-trip;流式专项测试 |
|
||||
| 修改 Provider 路由造成通用回归 | 非 reasoning 请求也受影响 | 国内 Provider 路由放第二阶段,独立提交和回归 |
|
||||
|
||||
## 14. 需要审核确认的决策
|
||||
|
||||
1. **是否同意三层分离**:能力、策略、展示互不替代,保留 `remove-think` 仅控制展示。
|
||||
2. **是否同意严格语义**:显式关闭无法准确执行时直接报错,不自动降为最低思考。
|
||||
3. **是否同意请求级配置**:流水线按模型 UUID 保存挡位,不把产品配置塞进 `extra_args`。
|
||||
4. **是否同意 Runner 边界**:仅 Local Agent 展示控制项,外部 Runner 由其外部系统管理模型策略。
|
||||
5. **是否同意保守默认**:所有现有模型迁移为 Provider Default,不自动开启、关闭或迁移旧高级参数。
|
||||
6. **是否把结构化 reasoning 保留纳入第一阶段**:这是 DeepSeek 多轮和工具调用正确性的必要条件,建议必须纳入。
|
||||
|
||||
## 15. 推荐审核结果
|
||||
|
||||
建议按以上 6 项全部通过,并将 Phase 1 作为一个完整功能单元实施。不要只增加前端开关或只在 `extra_args` 中写 `reasoning_effort`;那样虽然改动小,但会继续混淆展示与推理、无法处理 Provider 差异,也无法保证多轮对话正确性。
|
||||
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
|
||||
They show the whole loop: signing a request, pushing a message, and receiving
|
||||
multi-part replies on a callback endpoint.
|
||||
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://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).
|
||||
|
||||
## Files
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||
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)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
|
||||
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||
a single `<script>` tag.
|
||||
|
||||
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
||||
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
(`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)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
|
||||
+9
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.6"
|
||||
version = "4.10.10"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"pynacl>=1.5.0", # Required for Discord voice support
|
||||
"gewechat-client>=0.1.5",
|
||||
"lark-oapi>=1.5.5",
|
||||
"mcp>=1.25.0",
|
||||
"mcp>=1.25.0,<2.0.0",
|
||||
"nakuru-project-idk>=0.0.2.1",
|
||||
"ollama>=0.4.8",
|
||||
"openai>1.0.0",
|
||||
@@ -70,8 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
|
||||
"langbot-plugin==0.5.7",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -108,9 +107,14 @@ classifiers = [
|
||||
"Topic :: Communications :: Chat",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
seekdb = [
|
||||
"pyseekdb==1.1.0.post3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
Documentation = "https://langbot.app/docs"
|
||||
Repository = "https://github.com/langbot-app/LangBot"
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1349,7 +1349,8 @@
|
||||
"local-agent",
|
||||
"tools",
|
||||
"e2b",
|
||||
"nsjail"
|
||||
"nsjail",
|
||||
"host"
|
||||
],
|
||||
"automation": "",
|
||||
"setup_automation": [],
|
||||
|
||||
@@ -27,17 +27,19 @@ The `all` / `box` profile starts three services:
|
||||
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
|
||||
spawn sandbox containers, so the **Box root host path and in-container path
|
||||
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
|
||||
Its RPC and managed-process relay require a shared
|
||||
`LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
|
||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
||||
never put it in `box.runtime.endpoint` or commit it to config.
|
||||
OSS allows its RPC and managed-process relay to run without a token when both
|
||||
sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set
|
||||
the same value of at least 32 non-whitespace characters in both the LangBot
|
||||
and Box containers. Generate it once with `openssl rand -hex 32`; never put
|
||||
it in `box.runtime.endpoint` or commit it to config.
|
||||
|
||||
Every Compose deployment also needs one
|
||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
|
||||
`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
|
||||
before `docker compose up`; the external Plugin Runtime fails closed when the
|
||||
token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
|
||||
Secret shown in `docker/kubernetes.yaml`.
|
||||
A Compose deployment may optionally set
|
||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
||||
`langbot_plugin_runtime` when port 5400 needs shared-secret protection. OSS
|
||||
defaults to leaving it unset on both sides. If enabled, generate one value with
|
||||
`openssl rand -hex 32`; configuring only one side causes the control connection
|
||||
to fail. Kubernetes may use the `langbot-plugin-runtime-control` Secret shown in
|
||||
`docker/kubernetes.yaml`.
|
||||
|
||||
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
|
||||
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||
@@ -46,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||
## Kubernetes
|
||||
|
||||
See `docker/kubernetes.yaml` and the deployment guide at
|
||||
https://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)
|
||||
|
||||
@@ -61,7 +63,7 @@ Key settings:
|
||||
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
||||
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
||||
| `box.enabled` | Master switch for the Box sandbox runtime |
|
||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; 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 |
|
||||
|
||||
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||
@@ -73,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||
with `--standalone-runtime`.
|
||||
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
||||
`langbot_box:5410`.
|
||||
- `box.backend: host` runs commands directly as the Box Runtime system user.
|
||||
It is never auto-selected, provides no sandbox isolation, and is only for
|
||||
trusted local development. A WebSocket-controlled host backend requires
|
||||
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
|
||||
|
||||
## Global API key — enabling for agents/automation
|
||||
|
||||
@@ -91,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
|
||||
usually means the user isn't in the `docker` group →
|
||||
`sudo usermod -aG docker <user>` and restart in a new shell.
|
||||
- Do not use `box.backend: host` as a production fallback. It cannot enforce
|
||||
image, filesystem, network, PID, CPU, memory, or storage isolation.
|
||||
- Box root host/container path mismatch breaks sandbox container creation.
|
||||
- Don't commit a non-empty `api.global_api_key` to version control.
|
||||
|
||||
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||
already have a default pipeline.
|
||||
|
||||
## How to use
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ description: Browse and search the LangBot Space marketplaces (plugins, MCP serv
|
||||
# LangBot Space MCP Operations
|
||||
|
||||
LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI
|
||||
agents can browse and search the marketplaces (plugins, MCP servers, skills).
|
||||
agents can browse and search the marketplaces (plugins, MCP servers, skills) and
|
||||
rank live models for automated setup.
|
||||
|
||||
## Endpoint
|
||||
|
||||
@@ -46,10 +47,12 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
|
||||
| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace |
|
||||
| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace |
|
||||
| `list_skills` / `search_skills` / `get_skill` | Skill marketplace |
|
||||
| `select_models` | Live best-first model list for setup wizards; optional `category` filter |
|
||||
|
||||
`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes
|
||||
`author` + `name`. The tool surface mirrors the REST endpoints under
|
||||
`/api/v1/marketplace/*` and is read/browse only.
|
||||
`/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`.
|
||||
All tools are read-only.
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -58,12 +61,16 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
|
||||
3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items,
|
||||
then `get_*` for details (e.g. to obtain author/name for installation in
|
||||
LangBot itself).
|
||||
4. For automatic local-agent setup, call `select_models` (optionally with
|
||||
`category`) and choose the first compatible item. Ordering is latest probe
|
||||
state (available, unprobed, unavailable), then Space recommendation. Each
|
||||
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
|
||||
|
||||
## Implementation & maintenance (for Space developers)
|
||||
|
||||
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
|
||||
`github.com/modelcontextprotocol/go-sdk`). Tools call the service layer
|
||||
(`PluginService`, `MCPService`, `SkillService`) directly.
|
||||
(`PluginService`, `MCPService`, `SkillService`, `ModelStatusService`) directly.
|
||||
- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`.
|
||||
- Auth: PAT via `AccountService.ValidatePersonalAccessToken`.
|
||||
- Docs: `docs/MCP_SERVER.md`.
|
||||
|
||||
@@ -13,6 +13,7 @@ tags:
|
||||
- tools
|
||||
- e2b
|
||||
- nsjail
|
||||
- host
|
||||
skills:
|
||||
- langbot-env-setup
|
||||
- langbot-testing
|
||||
@@ -23,7 +24,7 @@ env:
|
||||
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
||||
preconditions:
|
||||
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
||||
- "LangBot is started with the 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."
|
||||
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."
|
||||
@@ -33,7 +34,7 @@ steps:
|
||||
checks:
|
||||
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
||||
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
||||
- "Logs: The selected backend name is the expected one, such as e2b 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."
|
||||
- "Box status: recent_error_count is 0 after the run."
|
||||
evidence_required:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
||||
|
||||
This flow applies to Docker, nsjail, 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
|
||||
|
||||
@@ -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=nsjail` when validating nsjail.
|
||||
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
||||
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
|
||||
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
||||
4. Confirm Debug Chat uses a model with function-calling ability.
|
||||
5. Confirm backend logs say native sandbox tools are available.
|
||||
@@ -71,7 +72,7 @@ Backend logs should show:
|
||||
- `register_skill`
|
||||
- `activate`
|
||||
- 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:
|
||||
|
||||
@@ -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.
|
||||
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
||||
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
||||
- The host backend does not honor sandbox image, network, rootfs, process, or
|
||||
resource isolation. Use a disposable workspace and low-privilege account.
|
||||
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
||||
|
||||
## Related Troubleshooting
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
|
||||
date: 2026-05-18
|
||||
symptoms:
|
||||
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
||||
- "The Box runtime later reports that E2B, nsjail, 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."
|
||||
patterns:
|
||||
- "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."
|
||||
- "For E2B, verify the key without printing it and confirm any required template setting."
|
||||
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
||||
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
|
||||
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
||||
related_cases:
|
||||
- sandbox-skill-authoring-e2e
|
||||
|
||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
||||
|___/
|
||||
|
||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||
📖 Documentation 文档地址: https://docs.langbot.app
|
||||
📖 Documentation 文档地址: https://langbot.app/docs
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import DifyAPIError
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
|
||||
@@ -15,6 +16,32 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
|
||||
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
|
||||
data = line[5:].strip()
|
||||
if not data or data == b'[DONE]':
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(data.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise DifyAPIError('Dify SSE event is not a JSON object')
|
||||
return payload
|
||||
|
||||
|
||||
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
|
||||
try:
|
||||
response = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise DifyAPIError('Dify upload response is not valid JSON') from exc
|
||||
if not isinstance(response, dict):
|
||||
raise DifyAPIError('Dify upload response is not a JSON object')
|
||||
payload = response.get('data', response)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
|
||||
raise DifyAPIError('Dify upload response does not contain a valid file id')
|
||||
return payload
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
@@ -56,16 +83,16 @@ async def _iter_sse_json(
|
||||
line = raw_line.rstrip(b'\r').strip()
|
||||
if not line or not line.startswith(b'data:'):
|
||||
continue
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
payload = _decode_sse_data(line)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
|
||||
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
||||
|
||||
line = bytes(buffer).rstrip(b'\r').strip()
|
||||
if line.startswith(b'data:'):
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
payload = _decode_sse_data(line)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
|
||||
|
||||
@@ -242,7 +269,7 @@ class AsyncDifyServiceClient:
|
||||
file: httpx._types.FileTypes,
|
||||
user: str,
|
||||
timeout: float = 30.0,
|
||||
) -> str:
|
||||
) -> dict[str, typing.Any]:
|
||||
# 处理 Path 对象
|
||||
if isinstance(file, Path):
|
||||
if not file.exists():
|
||||
@@ -271,6 +298,6 @@ class AsyncDifyServiceClient:
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
body = await _read_limited_response(response)
|
||||
if response.status_code != 201:
|
||||
if response.status_code not in (200, 201):
|
||||
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
||||
return json.loads(body)
|
||||
return _decode_upload_response(body)
|
||||
|
||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
||||
if not await self.check_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:
|
||||
cardData['config'] = json.dumps(card_data_config)
|
||||
template_params['config'] = card_data_config
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||
|
||||
body: dict = {
|
||||
'cardTemplateId': card_template_id,
|
||||
|
||||
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||
raise ValueError(response)
|
||||
|
||||
async def _send_markdown_msg(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
content: str,
|
||||
msg_id: Optional[str] = None,
|
||||
event_id: Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a Markdown message to a C2C user or QQ group."""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
if target_type == 'c2c':
|
||||
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||
elif target_type == 'group':
|
||||
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||
else:
|
||||
raise ValueError(f'Unsupported Markdown target type: {target_type}')
|
||||
|
||||
data: dict[str, Any] = {
|
||||
'msg_type': 2,
|
||||
'markdown': {'content': content},
|
||||
'msg_seq': msg_seq,
|
||||
}
|
||||
if msg_id:
|
||||
data['msg_id'] = msg_id
|
||||
if event_id:
|
||||
data['event_id'] = event_id
|
||||
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
if response.status_code != 200:
|
||||
response_data = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'Failed to send Markdown message: {response_data}')
|
||||
raise ValueError(response)
|
||||
|
||||
async def send_private_markdown_msg(
|
||||
self,
|
||||
user_openid: str,
|
||||
content: str,
|
||||
msg_id: Optional[str] = None,
|
||||
event_id: Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a Markdown C2C message."""
|
||||
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
|
||||
|
||||
async def send_group_markdown_msg(
|
||||
self,
|
||||
group_openid: str,
|
||||
content: str,
|
||||
msg_id: Optional[str] = None,
|
||||
event_id: Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a Markdown QQ group message."""
|
||||
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
|
||||
|
||||
async def send_group_text_msg(
|
||||
self,
|
||||
group_openid: str,
|
||||
|
||||
@@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
|
||||
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||
CMD_SEND_MSG = 'aibot_send_msg'
|
||||
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
|
||||
# command names below match the WeCom AI Bot long-connection protocol.
|
||||
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
|
||||
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
|
||||
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
|
||||
|
||||
# Default upload chunk size: 512 KB before base64 encoding.
|
||||
_UPLOAD_CHUNK_SIZE = 512 * 1024
|
||||
|
||||
_DEDUP_CACHE_MAX = 4096
|
||||
_STREAM_CACHE_MAX = 1024
|
||||
@@ -495,6 +503,145 @@ class WecomBotWsClient:
|
||||
body['chatid'] = chat_id
|
||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Media upload (image / voice / file)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
data: bytes,
|
||||
filename: str = 'attachment',
|
||||
media_type: str = 'file',
|
||||
) -> Optional[dict]:
|
||||
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
|
||||
|
||||
Implements the three-step protocol documented for the WeCom
|
||||
AI Bot:
|
||||
|
||||
1. ``aibot_upload_media_init`` — declare media type, file name,
|
||||
size, MD5 and chunk count; receive ``upload_id``.
|
||||
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
|
||||
bytes) until done; receive per-chunk ACK.
|
||||
3. ``aibot_upload_media_finish`` — finalize the upload; receive
|
||||
``media_id``.
|
||||
|
||||
Returns a dict with the final ``media_id`` (and the raw
|
||||
``finish`` ACK) on success, or ``None`` on any failure. The
|
||||
caller is expected to ignore the result and continue
|
||||
gracefully — the framework will keep working without media
|
||||
delivery.
|
||||
"""
|
||||
import base64 as _b64
|
||||
import hashlib as _hl
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
file_size = len(data)
|
||||
file_md5 = _hl.md5(data).hexdigest()
|
||||
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
|
||||
if total_chunks == 0:
|
||||
total_chunks = 1
|
||||
|
||||
# Step 1: init.
|
||||
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
|
||||
init_body = {
|
||||
'type': media_type,
|
||||
'filename': filename,
|
||||
'total_size': file_size,
|
||||
'total_chunks': total_chunks,
|
||||
'md5': file_md5,
|
||||
}
|
||||
init_ack = await self._send_reply(
|
||||
init_req_id,
|
||||
init_body,
|
||||
cmd=CMD_UPLOAD_INIT,
|
||||
)
|
||||
if not init_ack or init_ack.get('errcode', 0) != 0:
|
||||
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
|
||||
return None
|
||||
upload_id = (
|
||||
init_ack.get('upload_id')
|
||||
or init_ack.get('body', {}).get('upload_id')
|
||||
or init_ack.get('data', {}).get('upload_id')
|
||||
)
|
||||
if not upload_id:
|
||||
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
|
||||
return None
|
||||
|
||||
# Step 2: chunks.
|
||||
for index in range(total_chunks):
|
||||
start = index * _UPLOAD_CHUNK_SIZE
|
||||
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
|
||||
chunk_bytes = data[start:end]
|
||||
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
|
||||
chunk_body = {
|
||||
'upload_id': upload_id,
|
||||
'chunk_index': index,
|
||||
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
|
||||
}
|
||||
chunk_ack = await self._send_reply(
|
||||
chunk_req_id,
|
||||
chunk_body,
|
||||
cmd=CMD_UPLOAD_CHUNK,
|
||||
)
|
||||
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
|
||||
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
|
||||
return None
|
||||
|
||||
# Step 3: finish.
|
||||
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
|
||||
finish_body = {'upload_id': upload_id}
|
||||
finish_ack = await self._send_reply(
|
||||
finish_req_id,
|
||||
finish_body,
|
||||
cmd=CMD_UPLOAD_FINISH,
|
||||
)
|
||||
if not finish_ack or finish_ack.get('errcode', 0) != 0:
|
||||
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
|
||||
return None
|
||||
|
||||
media_id = (
|
||||
finish_ack.get('media_id')
|
||||
or finish_ack.get('body', {}).get('media_id')
|
||||
or finish_ack.get('data', {}).get('media_id')
|
||||
)
|
||||
if not media_id:
|
||||
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
|
||||
return None
|
||||
return {'media_id': media_id, 'ack': finish_ack}
|
||||
|
||||
async def _reply_media(
|
||||
self,
|
||||
req_id: str,
|
||||
media_id: str,
|
||||
kind: str,
|
||||
) -> Optional[dict]:
|
||||
"""Send a media reply (image / voice / file) referencing *media_id*.
|
||||
|
||||
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
|
||||
the standard ``aibot_respond_msg`` command with a per-kind
|
||||
body key (matches the convention documented for the WeCom
|
||||
AI Bot SDK).
|
||||
"""
|
||||
if kind not in {'image', 'voice', 'file'}:
|
||||
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
|
||||
return None
|
||||
body = {
|
||||
'msgtype': kind,
|
||||
kind: {'media_id': media_id},
|
||||
}
|
||||
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
|
||||
|
||||
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||
return await self._reply_media(req_id, media_id, 'image')
|
||||
|
||||
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||
return await self._reply_media(req_id, media_id, 'file')
|
||||
|
||||
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||
return await self._reply_media(req_id, media_id, 'voice')
|
||||
|
||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||
"""Push a streaming chunk for a given message ID.
|
||||
|
||||
@@ -789,6 +936,13 @@ class WecomBotWsClient:
|
||||
'chat_type': message_data.get('type', 'single'),
|
||||
}
|
||||
self._prune_stream_state()
|
||||
# Send an initial empty stream frame so the WeCom client
|
||||
# shows its built-in loading spinner while the pipeline
|
||||
# processes the message (e.g. RAG retrieval).
|
||||
try:
|
||||
await self.reply_stream(req_id, stream_id, '', finish=False)
|
||||
except Exception:
|
||||
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
|
||||
message_data['stream_id'] = stream_id
|
||||
message_data['req_id'] = req_id
|
||||
|
||||
|
||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
||||
raise Exception('Failed to send message')
|
||||
return data
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
@@ -19,7 +19,6 @@ class Permission(enum.StrEnum):
|
||||
WORKSPACE_VIEW = 'workspace.view'
|
||||
WORKSPACE_UPDATE = 'workspace.update'
|
||||
WORKSPACE_DELETE = 'workspace.delete'
|
||||
OWNER_TRANSFER = 'owner.transfer'
|
||||
MEMBER_VIEW = 'member.view'
|
||||
MEMBER_INVITE = 'member.invite'
|
||||
MEMBER_UPDATE_ROLE = 'member.update_role'
|
||||
@@ -49,7 +48,6 @@ _ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
||||
if permission
|
||||
not in {
|
||||
Permission.WORKSPACE_DELETE,
|
||||
Permission.OWNER_TRANSFER,
|
||||
Permission.BILLING_LINK_MANAGE,
|
||||
}
|
||||
),
|
||||
@@ -74,6 +72,11 @@ class AuthorizationError(Exception):
|
||||
error_code = 'forbidden'
|
||||
|
||||
|
||||
class AuthenticationDeniedError(AuthorizationError):
|
||||
status_code = 401
|
||||
error_code = 'invalid_authentication'
|
||||
|
||||
|
||||
class WorkspaceRequiredError(AuthorizationError):
|
||||
status_code = 400
|
||||
error_code = 'workspace_required'
|
||||
|
||||
@@ -9,6 +9,7 @@ class PrincipalType(enum.StrEnum):
|
||||
|
||||
ACCOUNT = 'account'
|
||||
API_KEY = 'api_key'
|
||||
SUPPORT_ADMIN = 'support_admin'
|
||||
SYSTEM = 'system'
|
||||
PUBLIC_BOT = 'public_bot'
|
||||
|
||||
@@ -19,7 +20,9 @@ class PrincipalContext:
|
||||
|
||||
principal_type: PrincipalType
|
||||
account_uuid: str | None = None
|
||||
actor_account_uuid: str | None = None
|
||||
api_key_uuid: str | None = None
|
||||
support_session_id: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -15,8 +15,17 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ....core.errors import TaskCapacityError
|
||||
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||
from ..authz import (
|
||||
AuthenticationDeniedError,
|
||||
AuthorizationError,
|
||||
Permission,
|
||||
PermissionDeniedError,
|
||||
WorkspaceRequiredError,
|
||||
permissions_for_role,
|
||||
require_permission,
|
||||
)
|
||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from ....cloud.support_admin import SupportAdminSessionError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
@@ -51,6 +60,16 @@ class AuthType(enum.Enum):
|
||||
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||
|
||||
|
||||
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
||||
{
|
||||
Permission.MEMBER_VIEW.value,
|
||||
Permission.MEMBER_INVITE.value,
|
||||
Permission.MEMBER_UPDATE_ROLE.value,
|
||||
Permission.MEMBER_REMOVE.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RouterGroup(abc.ABC):
|
||||
name: str
|
||||
|
||||
@@ -95,6 +114,10 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
|
||||
try:
|
||||
if self._is_support_admin_token(token):
|
||||
raise AuthenticationDeniedError(
|
||||
'Support admin tokens cannot be refreshed or used on account endpoints'
|
||||
)
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
# Account-token routes deliberately stop before Workspace
|
||||
# selection. They may bootstrap a selector, but cannot
|
||||
@@ -111,8 +134,13 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
|
||||
try:
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
request_context = await self._resolve_account_context(account, auth_type)
|
||||
request_context = await self._authenticate_support_admin(token, auth_type)
|
||||
if request_context is not None:
|
||||
self._require_support_admin_route_allowed(rule, f, permission)
|
||||
user_email = None
|
||||
else:
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
request_context = await self._resolve_account_context(account, auth_type)
|
||||
if permission is not None:
|
||||
if request_context is None:
|
||||
raise AuthorizationError('Workspace authorization is unavailable')
|
||||
@@ -141,10 +169,20 @@ class RouterGroup(abc.ABC):
|
||||
return self._auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if token and self._is_support_admin_token(token):
|
||||
try:
|
||||
request_context = await self._authenticate_support_admin(token, auth_type)
|
||||
if request_context is None:
|
||||
raise AuthenticationDeniedError('Invalid support admin token')
|
||||
self._require_support_admin_route_allowed(rule, f, permission)
|
||||
if permission is not None:
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, None, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
# Try API key first (check X-API-Key header)
|
||||
api_key = quart.request.headers.get('X-API-Key', '')
|
||||
|
||||
if api_key:
|
||||
elif api_key := quart.request.headers.get('X-API-Key', ''):
|
||||
# API key authentication
|
||||
try:
|
||||
request_context = await self._authenticate_api_key(api_key, auth_type)
|
||||
@@ -155,8 +193,6 @@ class RouterGroup(abc.ABC):
|
||||
return self._auth_error_response(e)
|
||||
else:
|
||||
# Try user token authentication (Authorization header)
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
|
||||
if not token:
|
||||
return self.http_status(
|
||||
401, -1, 'No valid authentication provided (user token or API key required)'
|
||||
@@ -268,10 +304,83 @@ class RouterGroup(abc.ABC):
|
||||
raise ValueError('User not found')
|
||||
return account, account.user
|
||||
|
||||
def _is_support_admin_token(self, token: str) -> bool:
|
||||
service = getattr(self.ap, 'support_admin_session_service', None)
|
||||
detector = getattr(service, 'is_support_admin_token', None)
|
||||
return callable(detector) and detector(token) is True
|
||||
|
||||
async def _authenticate_support_admin(
|
||||
self,
|
||||
token: str,
|
||||
auth_type: AuthType,
|
||||
*,
|
||||
workspace_uuid: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> RequestContext | None:
|
||||
service = getattr(self.ap, 'support_admin_session_service', None)
|
||||
detector = getattr(service, 'is_support_admin_token', None)
|
||||
if service is None or not callable(detector) or detector(token) is not True:
|
||||
return None
|
||||
|
||||
requested_workspace_uuid = (
|
||||
workspace_uuid if workspace_uuid is not None else quart.request.headers.get('X-Workspace-Id')
|
||||
)
|
||||
if not requested_workspace_uuid:
|
||||
raise WorkspaceRequiredError('Support admin token requires an explicit Workspace selector')
|
||||
try:
|
||||
identity = await service.authenticate_token(
|
||||
token,
|
||||
requested_workspace_uuid=requested_workspace_uuid,
|
||||
)
|
||||
except SupportAdminSessionError as exc:
|
||||
raise AuthenticationDeniedError(str(exc)) from exc
|
||||
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
identity.instance_uuid,
|
||||
identity.workspace_uuid,
|
||||
)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=identity.instance_uuid,
|
||||
placement_generation=identity.placement_generation,
|
||||
request_id=request_id or self.request_id(),
|
||||
auth_type=auth_type.value,
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.SUPPORT_ADMIN,
|
||||
actor_account_uuid=identity.actor_account_uuid,
|
||||
support_session_id=identity.grant_jti_hash,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=identity.workspace_uuid,
|
||||
membership_uuid=None,
|
||||
role='owner',
|
||||
permissions=permissions_for_role('owner') - _SUPPORT_ADMIN_DENIED_PERMISSIONS,
|
||||
membership_revision=0,
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
quart.g.workspace_membership = None
|
||||
return request_context
|
||||
|
||||
@staticmethod
|
||||
def _require_support_admin_route_allowed(
|
||||
rule: str,
|
||||
handler: RouteCallable,
|
||||
permission: Permission | str | None,
|
||||
) -> None:
|
||||
parameters = inspect.signature(handler).parameters
|
||||
if rule.startswith('/api/v1/user/') or 'account' in parameters or 'user_email' in parameters:
|
||||
raise AuthenticationDeniedError('Support admin tokens are not permitted on account endpoints')
|
||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
||||
if permission_value in _SUPPORT_ADMIN_DENIED_PERMISSIONS:
|
||||
raise PermissionDeniedError(permission_value)
|
||||
|
||||
async def _resolve_account_context(
|
||||
self,
|
||||
account: typing.Any,
|
||||
auth_type: AuthType,
|
||||
*,
|
||||
token: str | None = None,
|
||||
) -> RequestContext | None:
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
|
||||
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
user_query = quart.request.args.get('userQuery')
|
||||
is_active_str = quart.request.args.get('isActive')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_query=user_query,
|
||||
is_active=is_active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
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
|
||||
# The frontend will handle the 'found: false' case
|
||||
|
||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
||||
pipeline_data = await quart.request.json
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
request_context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -97,6 +97,16 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
if not token or not workspace_uuid:
|
||||
raise ValueError('Authentication is required')
|
||||
|
||||
support_context = await self._authenticate_support_admin(
|
||||
token,
|
||||
group.AuthType.USER_TOKEN,
|
||||
workspace_uuid=workspace_uuid,
|
||||
request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()),
|
||||
)
|
||||
if support_context is not None:
|
||||
require_permission(support_context, Permission.RUNTIME_OPERATE)
|
||||
return support_context, token
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
@@ -131,6 +141,23 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
) -> RequestContext:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
||||
current_context = await self._authenticate_support_admin(
|
||||
token,
|
||||
group.AuthType.USER_TOKEN,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
request_id=request_context.request_id,
|
||||
)
|
||||
if current_context is None or current_context.principal != request_context.principal:
|
||||
raise ValueError('WebSocket support admin session changed')
|
||||
if (
|
||||
current_context.instance_uuid != request_context.instance_uuid
|
||||
or current_context.placement_generation != request_context.placement_generation
|
||||
):
|
||||
raise ValueError('WebSocket authorization changed')
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
return current_context
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
if account_uuid != request_context.account_uuid:
|
||||
@@ -211,6 +238,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
scope=WebSocketScope.from_context(request_context),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
trigger_principal=request_context.principal,
|
||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||
send_queue_size=(
|
||||
self.ap.instance_config.data.get('system', {})
|
||||
@@ -391,7 +419,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
)
|
||||
elif message_type == 'message':
|
||||
try:
|
||||
await self._revalidate_websocket_authorization(request_context, token)
|
||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||
break
|
||||
|
||||
@@ -22,6 +22,7 @@ class _AdapterSessionScope:
|
||||
principal_type: str
|
||||
account_uuid: str | None
|
||||
api_key_uuid: str | None
|
||||
support_session_id: str | None
|
||||
|
||||
@classmethod
|
||||
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
|
||||
@@ -33,6 +34,7 @@ class _AdapterSessionScope:
|
||||
principal_type=principal.principal_type.value,
|
||||
account_uuid=principal.account_uuid,
|
||||
api_key_uuid=principal.api_key_uuid,
|
||||
support_session_id=principal.support_session_id,
|
||||
)
|
||||
|
||||
def matches(self, request_context: RequestContext) -> bool:
|
||||
|
||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
return self.success(data={'sent': True})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/test-inbound',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
str(json_data.get('message') or ''),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data=result)
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -15,7 +15,6 @@ import posixpath
|
||||
import sqlalchemy
|
||||
|
||||
from .....core import taskmgr
|
||||
from .....core.task_boundary import run_in_workspace_uow
|
||||
from .....entity.persistence import plugin as persistence_plugin
|
||||
from ...authz import Permission
|
||||
from ...context import ExecutionContext, RequestContext
|
||||
@@ -311,13 +310,22 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
):
|
||||
"""Revalidate a captured task context immediately before Runtime I/O."""
|
||||
|
||||
await run_in_workspace_uow(
|
||||
self.ap,
|
||||
execution_context.workspace_uuid,
|
||||
lambda: self.ap.plugin_connector.require_workspace_context(execution_context),
|
||||
)
|
||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
||||
tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
|
||||
if callable(tenant_scope):
|
||||
async with tenant_scope(execution_context.workspace_uuid):
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
return await operation()
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
return await operation()
|
||||
|
||||
async def _require_authenticated_plugin_runtime_context(
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
) -> ExecutionContext:
|
||||
"""Fence an authenticated resource request to its injected Workspace."""
|
||||
return await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
|
||||
"""Resolve public assets only for the OSS singleton Workspace.
|
||||
|
||||
@@ -372,7 +380,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
|
||||
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
||||
@@ -385,17 +393,27 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get plugin debug information including debug URL and key"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||
execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
|
||||
|
||||
# Get debug URL from config
|
||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
|
||||
debug_url = plugin_config.get(
|
||||
'display_plugin_debug_url',
|
||||
'ws://localhost:5401/plugin/debug/ws',
|
||||
)
|
||||
parsed_debug_url = urlparse(debug_url)
|
||||
if parsed_debug_url.scheme in {'http', 'https'}:
|
||||
debug_url = parsed_debug_url._replace(
|
||||
scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws',
|
||||
path=parsed_debug_url.path or '/plugin/debug/ws',
|
||||
).geturl()
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'debug_url': debug_url,
|
||||
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
||||
'expires_at': debug_info.get('expires_at', ''),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -428,7 +446,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
if plugin is None:
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
@@ -469,7 +487,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
if plugin is None:
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
@@ -489,7 +507,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
if plugin is None:
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
@@ -506,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||
return self.success(data={})
|
||||
|
||||
@@ -517,7 +535,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
language = quart.request.args.get('language', 'en')
|
||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||
return self.success(data={'readme': readme})
|
||||
@@ -529,7 +547,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
try:
|
||||
limit = int(quart.request.args.get('limit', 200))
|
||||
except (TypeError, ValueError):
|
||||
@@ -538,6 +556,44 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||
return self.success(data={'logs': logs})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/authenticated-icon',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(
|
||||
author: str,
|
||||
plugin_name: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
|
||||
icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64'])
|
||||
return quart.Response(icon_bytes, mimetype=icon_data['mime_type'])
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(
|
||||
author: str,
|
||||
plugin_name: str,
|
||||
filepath: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
asset_path = _normalize_plugin_asset_path(filepath)
|
||||
if asset_path is None:
|
||||
return quart.Response('Asset not found', status=404)
|
||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||
if not asset_data.get('asset_base64'):
|
||||
return quart.Response('Asset not found', status=404)
|
||||
asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64'])
|
||||
return quart.Response(asset_bytes, mimetype=asset_data['mime_type'])
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/icon',
|
||||
methods=['GET'],
|
||||
@@ -596,7 +652,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
"""Forward a page API request to the plugin."""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
data = await quart.request.json
|
||||
if not isinstance(data, dict):
|
||||
return self.http_status(400, -1, 'invalid request body')
|
||||
@@ -625,7 +681,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get releases from a GitHub repository URL"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
data = await quart.request.json
|
||||
repo_url = data.get('repo_url', '')
|
||||
|
||||
@@ -705,7 +761,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get assets from a specific GitHub release"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
data = await quart.request.json
|
||||
owner = data.get('owner', '')
|
||||
repo = data.get('repo', '')
|
||||
@@ -901,7 +957,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -942,7 +998,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Upload a file for plugin configuration"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -974,7 +1030,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(file_key: str, request_context: RequestContext) -> str:
|
||||
"""Delete a plugin configuration file"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
|
||||
return self.http_status(400, -1, 'invalid file key')
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
@@ -24,12 +21,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skills = await self.ap.skill_service.list_skills(request_context)
|
||||
except EntitlementFeatureUnavailableError:
|
||||
# Plans without managed sandbox support have no runnable skills.
|
||||
# Treat that capability absence as an empty collection so the
|
||||
# shared UI can render normally instead of surfacing a 500.
|
||||
return self.success(data={'skills': []})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
@@ -47,7 +39,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.create_skill(request_context, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -59,7 +51,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
@@ -77,13 +69,13 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||
return self.success()
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -105,7 +97,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
include_hidden=include_hidden,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -118,7 +110,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -136,7 +128,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -170,7 +162,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -194,7 +186,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -219,7 +211,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
source_paths=form.getlist('source_paths'),
|
||||
)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -242,7 +234,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
filename=file.filename or '',
|
||||
)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -261,5 +253,5 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.scan_directory_async(request_context, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/wizard/recommended-model',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Resolve Space's best available chat model to this Workspace."""
|
||||
try:
|
||||
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||
except ValueError as exc:
|
||||
return self.http_status(503, -1, str(exc))
|
||||
return self.success(data=model)
|
||||
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import datetime
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -13,13 +14,6 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
|
||||
|
||||
@group.group_class('user', '/api/v1/user')
|
||||
class UserRouterGroup(group.RouterGroup):
|
||||
@staticmethod
|
||||
def _origin(value: str) -> tuple[str, str, int | None] | None:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
|
||||
return None
|
||||
return parsed.scheme, parsed.hostname.casefold(), parsed.port
|
||||
|
||||
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
|
||||
parsed = urlsplit(redirect_uri)
|
||||
if (
|
||||
@@ -37,17 +31,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if query != {'mode': ['bind']}:
|
||||
raise ValueError('Invalid Space binding redirect_uri')
|
||||
elif query:
|
||||
raise ValueError('Invalid Space login redirect_uri')
|
||||
raise ValueError('Invalid LangBot Account login redirect_uri')
|
||||
|
||||
redirect_origin = self._origin(redirect_uri)
|
||||
api_config = self.ap.instance_config.data.get('api', {})
|
||||
trusted_origins = {
|
||||
self._origin(str(api_config.get(config_key, '') or '').strip())
|
||||
for config_key in ('webui_url', 'webhook_prefix')
|
||||
}
|
||||
trusted_origins.discard(None)
|
||||
if redirect_origin not in trusted_origins:
|
||||
raise ValueError('Untrusted redirect_uri origin')
|
||||
return redirect_uri
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@@ -201,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback'
|
||||
)
|
||||
launch_assertion = json_data.get('launch_assertion')
|
||||
workspace_uuid = json_data.get('workspace_uuid')
|
||||
|
||||
@@ -214,29 +202,58 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
if not state:
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
|
||||
workspace_created_ats: dict[str, int] = {}
|
||||
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
|
||||
binding = await self.ap.workspace_service.get_execution_binding()
|
||||
workspace_uuids = [binding.workspace_uuid]
|
||||
workspace_created_at = binding.workspace_created_at
|
||||
if workspace_created_at is not None:
|
||||
if workspace_created_at.tzinfo is None:
|
||||
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
|
||||
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
||||
code,
|
||||
workspace_uuids,
|
||||
workspace_created_ats,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# 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(
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
if launch_workspace_uuid:
|
||||
if target_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
launch_workspace_uuid,
|
||||
target_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
@@ -285,8 +302,25 @@ class UserRouterGroup(group.RouterGroup):
|
||||
request_context.workspace_uuid,
|
||||
)
|
||||
owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
|
||||
owner_space_bound = bool(owner and owner.space_account_uuid)
|
||||
credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
owner_has_local_space_credentials = bool(owner and owner.space_account_uuid)
|
||||
# Cloud Accounts authenticate through LangBot Account, so every projected
|
||||
# Workspace owner is already bound even when this Core has no local OAuth
|
||||
# token row (model billing uses the owner's control-plane API key).
|
||||
owner_space_bound = cloud_mode or owner_has_local_space_credentials
|
||||
if cloud_mode:
|
||||
catalog_service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
||||
credits = (
|
||||
catalog_service.get_workspace_credits(access.workspace.uuid)
|
||||
if catalog_service is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
credits = (
|
||||
await self.ap.space_service.get_credits(owner.user)
|
||||
if owner is not None and owner.space_account_uuid
|
||||
else None
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'credits': credits,
|
||||
@@ -302,8 +336,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.success(data={'initialized': False})
|
||||
|
||||
capabilities = await self.ap.user_service.get_login_capabilities()
|
||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if cloud_mode:
|
||||
capabilities['password_login_enabled'] = False
|
||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@@ -348,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
|
||||
)
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||
@@ -366,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||
|
||||
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)
|
||||
return self.success(
|
||||
data={
|
||||
@@ -382,7 +427,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
'Bind the LangBot Account with the same email as this local Account',
|
||||
)
|
||||
except ValueError:
|
||||
return self.http_status(400, -1, 'Space account binding failed')
|
||||
return self.http_status(400, -1, 'LangBot Account binding failed')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@@ -396,6 +441,23 @@ class UserRouterGroup(group.RouterGroup):
|
||||
launch_assertion,
|
||||
expected_workspace_uuid=workspace_uuid,
|
||||
)
|
||||
if launch.get('launch_mode') == 'support_admin':
|
||||
token = launch.get('support_admin_token')
|
||||
if not token:
|
||||
raise SpaceLaunchError('Support admin launch session was not issued')
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
'workspace_uuid': launch['workspace_uuid'],
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': launch['actor_account_uuid'],
|
||||
}
|
||||
)
|
||||
|
||||
projection_service = self.ap.directory_projection_service
|
||||
if projection_service is None:
|
||||
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
|
||||
@@ -5,7 +5,7 @@ import typing
|
||||
import quart
|
||||
|
||||
from ...authz import Permission, permissions_for_role
|
||||
from ...context import RequestContext
|
||||
from ...context import PrincipalType, RequestContext
|
||||
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
|
||||
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
|
||||
from .....entity.persistence.workspace import WorkspaceSource
|
||||
@@ -30,12 +30,14 @@ def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
|
||||
def _membership_payload(
|
||||
membership: WorkspaceMembership,
|
||||
*,
|
||||
display_name: str,
|
||||
email: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'uuid': membership.uuid,
|
||||
'workspace_uuid': membership.workspace_uuid,
|
||||
'account_uuid': membership.account_uuid,
|
||||
'display_name': display_name,
|
||||
'email': email,
|
||||
'role': membership.role,
|
||||
'status': membership.status,
|
||||
@@ -94,7 +96,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
workspaces.append(
|
||||
{
|
||||
'workspace': _workspace_payload(access.workspace),
|
||||
'membership': _membership_payload(access.membership, email=account.user),
|
||||
'membership': _membership_payload(
|
||||
access.membership,
|
||||
display_name=account.user,
|
||||
email=account.normalized_email,
|
||||
),
|
||||
'permissions': sorted(permissions_for_role(access.membership.role)),
|
||||
'placement_generation': access.execution.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
@@ -120,9 +126,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
membership = quart.g.workspace_membership
|
||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||
if account is None:
|
||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
|
||||
plan_name: str | None = None
|
||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||
@@ -132,10 +135,37 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
minimum_revision=request_context.entitlement_revision,
|
||||
)
|
||||
plan_name = entitlement.plan_name
|
||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
||||
return self.success(
|
||||
data={
|
||||
'workspace': _workspace_payload(workspace),
|
||||
'membership': {
|
||||
'uuid': None,
|
||||
'workspace_uuid': request_context.workspace_uuid,
|
||||
'account_uuid': None,
|
||||
'display_name': None,
|
||||
'email': None,
|
||||
'role': 'owner',
|
||||
'status': 'active',
|
||||
'joined_at': None,
|
||||
'created_at': None,
|
||||
},
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
}
|
||||
)
|
||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||
if account is None:
|
||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||
return self.success(
|
||||
data={
|
||||
'workspace': _workspace_payload(workspace),
|
||||
'membership': _membership_payload(membership, email=account.user),
|
||||
'membership': _membership_payload(
|
||||
membership,
|
||||
display_name=account.user,
|
||||
email=account.normalized_email,
|
||||
),
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
@@ -264,7 +294,8 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
data={
|
||||
'member': _membership_payload(
|
||||
member,
|
||||
email=account.user if account is not None else '',
|
||||
display_name=account.user if account is not None else '',
|
||||
email=account.normalized_email if account is not None else '',
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -283,7 +314,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
|
||||
@staticmethod
|
||||
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
||||
return _membership_payload(view.membership, email=view.email)
|
||||
return _membership_payload(
|
||||
view.membership,
|
||||
display_name=view.display_name,
|
||||
email=view.email,
|
||||
)
|
||||
|
||||
|
||||
@group.group_class('invitations', '/api/v1/invitations')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
|
||||
|
||||
class BotService:
|
||||
@@ -80,6 +83,7 @@ class BotService:
|
||||
'wecomcs',
|
||||
'LINE',
|
||||
'lark',
|
||||
'http_bot',
|
||||
]:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||
@@ -133,7 +137,16 @@ class BotService:
|
||||
|
||||
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']
|
||||
|
||||
@@ -216,6 +229,53 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def send_http_bot_test_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_uuid: str,
|
||||
message: str,
|
||||
) -> dict:
|
||||
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
if bot is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
if bot.get('adapter') != 'http_bot':
|
||||
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||
if not bot.get('enable'):
|
||||
raise ValueError('Bot must be enabled before sending a test message')
|
||||
|
||||
text = message.strip()
|
||||
if not text or len(text) > 2000:
|
||||
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||
|
||||
payload = {
|
||||
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||
config = bot.get('adapter_config') or {}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if config.get('signature_required', True):
|
||||
secret = str(config.get('inbound_secret') or '')
|
||||
if not secret:
|
||||
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||
|
||||
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||
session = httpclient.get_session()
|
||||
async with session.post(
|
||||
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||
data=body,
|
||||
headers=headers,
|
||||
) as response:
|
||||
result = await httpclient.read_json_limited(response)
|
||||
if response.status not in {200, 202}:
|
||||
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||
return result.get('data') or {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
|
||||
@@ -5,10 +5,12 @@ import uuid
|
||||
import sqlalchemy
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....provider.modelmgr import requester as model_requester
|
||||
from ....provider.modelmgr import reasoning as model_reasoning
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
@@ -54,6 +56,53 @@ def _redact_model_secrets(model_data: dict) -> dict:
|
||||
return redacted
|
||||
|
||||
|
||||
def _normalize_llm_reasoning(model_data: dict) -> None:
|
||||
model_data['reasoning_config'] = model_reasoning.validate_reasoning_config(
|
||||
model_data.get('reasoning_config'),
|
||||
model_data.get('abilities'),
|
||||
model_data.get('extra_args'),
|
||||
)
|
||||
|
||||
|
||||
def _validate_llm_reasoning_capability(
|
||||
model_entity: persistence_model.LLMModel,
|
||||
runtime_provider: model_requester.RuntimeProvider,
|
||||
) -> None:
|
||||
config = model_reasoning.normalize_reasoning_config(model_entity.reasoning_config)
|
||||
if config['level'] == 'provider_default':
|
||||
return
|
||||
|
||||
runtime_model = model_requester.RuntimeLLMModel(
|
||||
execution_context=runtime_provider.execution_context,
|
||||
model_entity=model_entity,
|
||||
provider=runtime_provider,
|
||||
)
|
||||
capabilities = runtime_provider.requester.get_reasoning_capabilities(runtime_model)
|
||||
model_reasoning.validate_reasoning_capabilities(config, capabilities, model_entity.name)
|
||||
|
||||
|
||||
def _reasoning_capabilities(ap: app.Application, model: persistence_model.LLMModel) -> dict:
|
||||
model_mgr = getattr(ap, 'model_mgr', None)
|
||||
runtime_models = getattr(model_mgr, 'llm_model_dict', {}) if model_mgr is not None else {}
|
||||
for runtime_model in runtime_models.values():
|
||||
if (
|
||||
runtime_model.model_entity.uuid == model.uuid
|
||||
and runtime_model.model_entity.workspace_uuid == model.workspace_uuid
|
||||
):
|
||||
return runtime_model.provider.requester.get_reasoning_capabilities(runtime_model)
|
||||
return model_reasoning.default_reasoning_capabilities(
|
||||
supported='reasoning' in (model.abilities or []),
|
||||
source='manual' if 'reasoning' in (model.abilities or []) else 'unknown',
|
||||
)
|
||||
|
||||
|
||||
def _serialize_llm_model(ap: app.Application, model: persistence_model.LLMModel) -> dict:
|
||||
model_dict = ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||
model_dict['reasoning_config'] = model_reasoning.normalize_reasoning_config(model_dict.get('reasoning_config'))
|
||||
model_dict['reasoning_capabilities'] = _reasoning_capabilities(ap, model)
|
||||
return model_dict
|
||||
|
||||
|
||||
async def _validate_provider_supports(
|
||||
ap: app.Application,
|
||||
context: TenantContext,
|
||||
@@ -113,6 +162,23 @@ async def _require_workspace_provider(
|
||||
return provider
|
||||
|
||||
|
||||
def _is_cloud_runtime(ap: app.Application) -> bool:
|
||||
mode = getattr(ap.persistence_mgr, 'mode', None)
|
||||
return getattr(mode, 'value', None) == 'cloud_runtime'
|
||||
|
||||
|
||||
async def _assert_cloud_managed_provider_mutable(
|
||||
ap: app.Application,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
) -> None:
|
||||
if not _is_cloud_runtime(ap):
|
||||
return
|
||||
provider = await _require_workspace_provider(ap, context, provider_uuid)
|
||||
if provider.get('requester') == LANGBOT_MODELS_PROVIDER_REQUESTER:
|
||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
||||
|
||||
|
||||
async def _require_runtime_provider(
|
||||
ap: app.Application,
|
||||
context: TenantContext,
|
||||
@@ -147,7 +213,7 @@ class LLMModelsService:
|
||||
|
||||
models_list = []
|
||||
for model in models:
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||
model_dict = _serialize_llm_model(self.ap, model)
|
||||
provider = providers.get(model.provider_uuid)
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
@@ -178,7 +244,7 @@ class LLMModelsService:
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
|
||||
serialized = [_serialize_llm_model(self.ap, model) for model in models]
|
||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||
|
||||
async def create_llm_model(
|
||||
@@ -213,14 +279,19 @@ class LLMModelsService:
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
||||
_normalize_llm_reasoning(model_data)
|
||||
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
|
||||
model_entity = persistence_model.LLMModel(**model_data)
|
||||
_validate_llm_reasoning_capability(model_entity, runtime_provider)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
|
||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||
context,
|
||||
persistence_model.LLMModel(**model_data),
|
||||
model_entity,
|
||||
runtime_provider,
|
||||
)
|
||||
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
||||
@@ -268,7 +339,7 @@ class LLMModelsService:
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
|
||||
model_dict = _serialize_llm_model(self.ap, model)
|
||||
|
||||
# Get provider
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -291,11 +362,17 @@ class LLMModelsService:
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||
async def update_llm_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
model_data: dict,
|
||||
) -> None:
|
||||
"""Update an existing LLM model"""
|
||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||
model_data = model_data.copy()
|
||||
model_data.pop('uuid', None)
|
||||
model_data.pop('workspace_uuid', None)
|
||||
@@ -321,8 +398,21 @@ class LLMModelsService:
|
||||
|
||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
||||
|
||||
merged_model_data = {
|
||||
key: value
|
||||
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
|
||||
if key not in {'provider', 'created_at', 'updated_at', 'reasoning_capabilities'}
|
||||
}
|
||||
_normalize_llm_reasoning(merged_model_data)
|
||||
model_data['reasoning_config'] = merged_model_data['reasoning_config']
|
||||
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
|
||||
model_entity = persistence_model.LLMModel(**_runtime_model_data(model_uuid, merged_model_data))
|
||||
_validate_llm_reasoning_capability(model_entity, runtime_provider)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_model.LLMModel)
|
||||
@@ -336,25 +426,20 @@ class LLMModelsService:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
|
||||
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
|
||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||
context,
|
||||
persistence_model.LLMModel(
|
||||
**_runtime_model_data(
|
||||
model_uuid,
|
||||
{
|
||||
key: value
|
||||
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
|
||||
if key not in {'provider', 'created_at', 'updated_at'}
|
||||
},
|
||||
)
|
||||
),
|
||||
model_entity,
|
||||
runtime_provider,
|
||||
)
|
||||
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
||||
|
||||
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
"""Delete an LLM model"""
|
||||
if _is_cloud_runtime(self.ap):
|
||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
||||
@@ -376,6 +461,7 @@ class LLMModelsService:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
|
||||
else:
|
||||
_normalize_llm_reasoning(model_data)
|
||||
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data)
|
||||
|
||||
extra_args = model_data.get('extra_args', {})
|
||||
@@ -448,7 +534,10 @@ class EmbeddingModelsService:
|
||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||
|
||||
async def create_embedding_model(
|
||||
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_data: dict,
|
||||
preserve_uuid: bool = False,
|
||||
) -> str:
|
||||
"""Create a new embedding model"""
|
||||
model_data = model_data.copy()
|
||||
@@ -472,6 +561,7 @@ class EmbeddingModelsService:
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
@@ -530,11 +620,17 @@ class EmbeddingModelsService:
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||
async def update_embedding_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
model_data: dict,
|
||||
) -> None:
|
||||
"""Update an existing embedding model"""
|
||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||
model_data = model_data.copy()
|
||||
model_data.pop('uuid', None)
|
||||
model_data.pop('workspace_uuid', None)
|
||||
@@ -559,6 +655,7 @@ class EmbeddingModelsService:
|
||||
|
||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -593,6 +690,11 @@ class EmbeddingModelsService:
|
||||
|
||||
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
"""Delete an embedding model"""
|
||||
if _is_cloud_runtime(self.ap):
|
||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||
@@ -685,7 +787,12 @@ class RerankModelsService:
|
||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||
|
||||
async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||
async def create_rerank_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_data: dict,
|
||||
preserve_uuid: bool = False,
|
||||
) -> str:
|
||||
"""Create a new rerank model"""
|
||||
model_data = model_data.copy()
|
||||
if not preserve_uuid:
|
||||
@@ -708,6 +815,7 @@ class RerankModelsService:
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
|
||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
@@ -766,11 +874,17 @@ class RerankModelsService:
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||
async def update_rerank_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
model_data: dict,
|
||||
) -> None:
|
||||
"""Update an existing rerank model"""
|
||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||
model_data = model_data.copy()
|
||||
model_data.pop('uuid', None)
|
||||
model_data.pop('workspace_uuid', None)
|
||||
@@ -795,6 +909,7 @@ class RerankModelsService:
|
||||
|
||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
|
||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -829,6 +944,11 @@ class RerankModelsService:
|
||||
|
||||
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
"""Delete a rerank model"""
|
||||
if _is_cloud_runtime(self.ap):
|
||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await _assert_cloud_managed_provider_mutable(self.ap, context, existing_model['provider_uuid'])
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.RerankModel).where(
|
||||
|
||||
@@ -1257,6 +1257,7 @@ class MonitoringService:
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
user_query: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -1274,6 +1275,14 @@ class MonitoringService:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
sqlalchemy.or_(
|
||||
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||
)
|
||||
)
|
||||
if is_active is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||
|
||||
@@ -1365,6 +1374,8 @@ class MonitoringService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get bounded session details with full statistics computed in SQL."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -1478,12 +1489,17 @@ class MonitoringService:
|
||||
)
|
||||
)
|
||||
tool_stats = tool_stats_result.one()
|
||||
tool_conditions = [
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
]
|
||||
if start_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||
tool_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
)
|
||||
.where(*tool_conditions)
|
||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||
.limit(detail_limit + 1)
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import traceback
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
@@ -20,6 +21,20 @@ class ModelProviderService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _is_cloud_runtime(self) -> bool:
|
||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
||||
return getattr(mode, 'value', None) == 'cloud_runtime'
|
||||
|
||||
def _system_requester_is_reserved(self, requester: object) -> bool:
|
||||
return self._is_cloud_runtime() and requester == LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
|
||||
async def _assert_provider_mutable(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
if not self._is_cloud_runtime():
|
||||
return
|
||||
provider = await self.get_provider(context, provider_uuid)
|
||||
if provider is not None and self._system_requester_is_reserved(provider.get('requester')):
|
||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
||||
|
||||
@staticmethod
|
||||
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
||||
if api_keys is None:
|
||||
@@ -99,6 +114,8 @@ class ModelProviderService:
|
||||
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
||||
"""Create a new provider"""
|
||||
provider_data = provider_data.copy()
|
||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
provider_data['uuid'] = str(uuid.uuid4())
|
||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||
provider_data['api_keys'] = self._normalize_api_keys(
|
||||
@@ -115,7 +132,10 @@ class ModelProviderService:
|
||||
|
||||
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
||||
"""Update an existing provider"""
|
||||
await self._assert_provider_mutable(context, provider_uuid)
|
||||
provider_data = provider_data.copy()
|
||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
provider_data.pop('uuid', None)
|
||||
provider_data.pop('workspace_uuid', None)
|
||||
if 'api_keys' in provider_data:
|
||||
@@ -145,6 +165,7 @@ class ModelProviderService:
|
||||
|
||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
"""Delete a provider (only if no models reference it)"""
|
||||
await self._assert_provider_mutable(context, provider_uuid)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check if any models use this provider
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -245,6 +266,8 @@ class ModelProviderService:
|
||||
api_keys: list,
|
||||
) -> str:
|
||||
"""Find existing provider or create new one"""
|
||||
if self._system_requester_is_reserved(requester):
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
|
||||
'description',
|
||||
'instructions',
|
||||
'package_root',
|
||||
'revision',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
)
|
||||
@@ -53,38 +54,11 @@ class SkillService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _box_service(self):
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and getattr(box_service, 'available', False):
|
||||
return box_service
|
||||
return None
|
||||
|
||||
def _require_box(self, action: str):
|
||||
"""Return the Box service or raise if it is not available.
|
||||
|
||||
Box is the only source of truth for skills. Every read and write
|
||||
operation goes through it — there is no local-filesystem fallback.
|
||||
"""
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
return box_service
|
||||
ap_box = getattr(self.ap, 'box_service', None)
|
||||
if ap_box is None:
|
||||
reason = 'not initialised'
|
||||
elif not getattr(ap_box, 'enabled', True):
|
||||
reason = 'disabled in config (box.enabled = false)'
|
||||
else:
|
||||
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
|
||||
reason = f'unavailable: {connector_error}'
|
||||
raise ValueError(
|
||||
f'{action} requires the Box runtime, which is {reason}. '
|
||||
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
|
||||
f'runtime is reachable before retrying.'
|
||||
)
|
||||
|
||||
def _require_box_for_write(self, action: str) -> None:
|
||||
"""Backwards-compatible alias preserved for clarity at call sites."""
|
||||
self._require_box(action)
|
||||
def _repository(self):
|
||||
repository = getattr(self.ap, 'skill_repository', None)
|
||||
if repository is None:
|
||||
raise ValueError('Skill repository is not initialised')
|
||||
return repository
|
||||
|
||||
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -113,20 +87,11 @@ class SkillService:
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
# When Box is unavailable, surface an empty list rather than raising —
|
||||
# the skills page should render cleanly, and the UI separately renders
|
||||
# a "Box disabled / unavailable" banner via useBoxStatus.
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return []
|
||||
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
|
||||
return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)]
|
||||
|
||||
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return None
|
||||
skill = await box_service.get_skill(execution_context, skill_name)
|
||||
skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True)
|
||||
return self._serialize_skill(skill) if skill else None
|
||||
|
||||
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
|
||||
@@ -134,22 +99,25 @@ class SkillService:
|
||||
|
||||
async def create_skill(self, context: TenantContext, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Creating a skill')
|
||||
created = await box_service.create_skill(execution_context, data)
|
||||
created = await self._repository().create_skill(execution_context, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def import_skill_directory(self, context: TenantContext, path: str, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
created = await self._repository().import_skill_directory(execution_context, path, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Editing a skill')
|
||||
updated = await box_service.update_skill(execution_context, skill_name, data)
|
||||
updated = await self._repository().update_skill(execution_context, skill_name, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(updated)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Deleting a skill')
|
||||
await box_service.delete_skill(execution_context, skill_name)
|
||||
await self._repository().delete_skill(execution_context, skill_name)
|
||||
await self._reload_skills(execution_context)
|
||||
return True
|
||||
|
||||
@@ -162,24 +130,27 @@ class SkillService:
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Browsing skill files')
|
||||
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
|
||||
return await self._repository().list_skill_files(
|
||||
execution_context,
|
||||
skill_name,
|
||||
path,
|
||||
include_hidden,
|
||||
max_entries,
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Reading a skill file')
|
||||
return await box_service.read_skill_file(execution_context, skill_name, path)
|
||||
return await self._repository().read_skill_file(execution_context, skill_name, path)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Editing skill files')
|
||||
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
|
||||
result = await self._repository().write_skill_file(execution_context, skill_name, path, content)
|
||||
await self._reload_skills(execution_context)
|
||||
return result
|
||||
|
||||
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -198,7 +169,7 @@ class SkillService:
|
||||
|
||||
zip_bytes = await self._download_github_asset(asset_url)
|
||||
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await repository.install_skill_zip(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
@@ -211,7 +182,7 @@ class SkillService:
|
||||
|
||||
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -228,7 +199,7 @@ class SkillService:
|
||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||
|
||||
zip_bytes = await self._download_github_asset(asset_url)
|
||||
return await box_service.preview_skill_zip(
|
||||
return await repository.preview_skill_zip(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
||||
@@ -245,8 +216,7 @@ class SkillService:
|
||||
source_path: str = '',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Installing a skill from upload')
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await self._repository().install_skill_zip(
|
||||
execution_context,
|
||||
file_bytes,
|
||||
filename,
|
||||
@@ -264,8 +234,7 @@ class SkillService:
|
||||
filename: str,
|
||||
) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Previewing a skill upload')
|
||||
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
|
||||
return await self._repository().preview_skill_zip(execution_context, file_bytes, filename)
|
||||
|
||||
async def _install_github_skill_md(
|
||||
self,
|
||||
@@ -276,14 +245,14 @@ class SkillService:
|
||||
repo: str,
|
||||
data: dict,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await repository.install_skill_zip(
|
||||
context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
@@ -302,13 +271,13 @@ class SkillService:
|
||||
owner: str,
|
||||
repo: str,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
return await repository.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
|
||||
async def reload_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
@@ -317,8 +286,7 @@ class SkillService:
|
||||
|
||||
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Scanning a skill directory')
|
||||
return await box_service.scan_skill_directory(execution_context, path)
|
||||
return await self._repository().scan_skill_directory(execution_context, path)
|
||||
|
||||
async def _reload_skills(self, context: TenantContext) -> None:
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
|
||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....entity.dto.space_model import SpaceModel
|
||||
from ....entity.dto.space_model import SpaceModelSelection
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
|
||||
|
||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||
@@ -59,6 +62,10 @@ class SpaceService:
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list else None
|
||||
|
||||
async def get_valid_access_token(self, user_email: str) -> str | None:
|
||||
"""Return a current Space bearer, refreshing and persisting it when needed."""
|
||||
return await self._ensure_valid_token(user_email)
|
||||
|
||||
async def _ensure_valid_token(self, user_email: str) -> str | None:
|
||||
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
|
||||
user_obj = await self._get_user_by_email(user_email)
|
||||
@@ -112,12 +119,19 @@ class SpaceService:
|
||||
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = {'redirect_uri': redirect_uri}
|
||||
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||
if state:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
|
||||
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
||||
async def exchange_oauth_code(
|
||||
self,
|
||||
code: str,
|
||||
workspace_uuids: list[str] | None = None,
|
||||
workspace_created_ats: dict[str, int] | None = None,
|
||||
*,
|
||||
redirect_uri: str = '',
|
||||
) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
@@ -127,7 +141,15 @@ class SpaceService:
|
||||
session = httpclient.get_session()
|
||||
async with session.post(
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={'code': code, 'instance_id': constants.instance_id},
|
||||
json={
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'instance_id': constants.instance_id,
|
||||
# Sending an explicit empty list tells new Space servers not to
|
||||
# synthesize a legacy instance-derived Workspace binding.
|
||||
'workspace_uuids': workspace_uuids if workspace_uuids is not None else [],
|
||||
'workspace_created_ats': workspace_created_ats or {},
|
||||
},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error = await httpclient.read_text_limited(response)
|
||||
@@ -222,3 +244,76 @@ class SpaceService:
|
||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||
models_data = data.get('data', {}).get('models', [])
|
||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||
|
||||
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||
"""Return Space models in the availability-ranked selection order."""
|
||||
space_url = self._get_space_config()['url']
|
||||
session = httpclient.get_session()
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/models/selection',
|
||||
params={'category': category},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error = await httpclient.read_text_limited(response)
|
||||
raise ValueError(f'Failed to get model selection: {error}')
|
||||
payload = await httpclient.read_json_limited(response)
|
||||
if payload.get('code') != 0:
|
||||
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||
|
||||
data = payload.get('data', [])
|
||||
if isinstance(data, dict):
|
||||
data = data.get('models', data.get('items', []))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError('Failed to get model selection: invalid response')
|
||||
|
||||
models = []
|
||||
for selection in data:
|
||||
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||
models.append(selection['model'])
|
||||
else:
|
||||
models.append(selection)
|
||||
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||
|
||||
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||
selection = await self.get_model_selection('chat')
|
||||
if not selection:
|
||||
raise ValueError('No recommended chat model is available')
|
||||
recommended = selection[0]
|
||||
|
||||
async def find_local_model():
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel)
|
||||
.join(
|
||||
persistence_model.ModelProvider,
|
||||
sqlalchemy.and_(
|
||||
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
sqlalchemy.or_(
|
||||
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||
persistence_model.LLMModel.name == recommended.model_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
local_model = await find_local_model()
|
||||
if local_model is None:
|
||||
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||
# the recommendation was published after this process started.
|
||||
from ..context import ExecutionContext
|
||||
|
||||
try:
|
||||
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||
except Exception:
|
||||
pass
|
||||
local_model = await find_local_model()
|
||||
|
||||
if local_model is None:
|
||||
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||
|
||||
@@ -114,7 +114,7 @@ class UserService:
|
||||
if purpose == 'login' and account_uuid is not None:
|
||||
raise ValueError('Login state cannot be bound to an Account')
|
||||
if purpose != 'login' and launch_workspace_uuid is not None:
|
||||
raise ValueError('Launch Workspace state is only valid for Space login')
|
||||
raise ValueError('Launch Workspace state is only valid for LangBot Account login')
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError('OAuth state lifetime must be positive')
|
||||
|
||||
@@ -327,7 +327,7 @@ class UserService:
|
||||
normalized_email = normalize_email(user_email)
|
||||
if self._uses_control_plane_directory():
|
||||
raise ControlPlaneDirectoryRequiredError(
|
||||
'Cloud invitation registration must use a Space account to preserve control-plane identity'
|
||||
'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
|
||||
)
|
||||
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
||||
if invitation.normalized_email != normalized_email:
|
||||
@@ -394,13 +394,16 @@ class UserService:
|
||||
|
||||
# Check if this user has a local password set
|
||||
if not user_obj.password:
|
||||
raise ValueError('请使用 Space 账户登录')
|
||||
raise ValueError('请使用 LangBot 账号登录')
|
||||
|
||||
await self._verify_password(user_obj.password, password)
|
||||
|
||||
return await self.generate_jwt_token(user_obj)
|
||||
|
||||
async def generate_jwt_token(self, account: user.User | str) -> str:
|
||||
async def generate_jwt_token(
|
||||
self,
|
||||
account: user.User | str,
|
||||
) -> str:
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
|
||||
|
||||
@@ -413,7 +416,7 @@ class UserService:
|
||||
# Lightweight unit-test and bootstrap callers may not have persistence wired.
|
||||
account_obj = None
|
||||
|
||||
payload = {
|
||||
payload: dict[str, typing.Any] = {
|
||||
'user': user_email,
|
||||
'iss': self._jwt_identity()[0],
|
||||
'aud': self._jwt_identity()[1],
|
||||
@@ -771,13 +774,33 @@ class UserService:
|
||||
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"""
|
||||
local_account = await self.get_user_by_email(user_email)
|
||||
if local_account is None:
|
||||
raise ValueError('User not found')
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
# Exchange code for tokens and bind both installation and the active
|
||||
# OSS Workspace as independent identities.
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
if workspace_service is not None:
|
||||
binding = await workspace_service.get_execution_binding()
|
||||
created_at = binding.workspace_created_at
|
||||
created_ts = (
|
||||
int(created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||
if created_at.tzinfo is None
|
||||
else int(created_at.timestamp())
|
||||
)
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
||||
code,
|
||||
[binding.workspace_uuid],
|
||||
{binding.workspace_uuid: created_ts},
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
else:
|
||||
# Compatibility for early/bootstrap call sites that have not wired
|
||||
# WorkspaceService yet; old Space servers still derive the legacy
|
||||
# Workspace identity from instance_id when the field is omitted.
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
@@ -803,7 +826,7 @@ class UserService:
|
||||
# Check if this Space account is already bound to another user
|
||||
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
|
||||
raise ValueError('This Space account is already bound to another user')
|
||||
raise ValueError('This LangBot Account is already bound to another user')
|
||||
|
||||
# Update local account to Space account
|
||||
normalized_email = normalize_email(user_email)
|
||||
|
||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
||||
)
|
||||
async def create_pipeline(pipeline_data: dict) -> str:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
return _dump(
|
||||
{
|
||||
'uuid': await ap.pipeline_service.create_pipeline(
|
||||
context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||
|
||||
@@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
- An explicit ``runtime.endpoint`` was configured
|
||||
|
||||
When this is True the Box runtime lives in a separate process with its
|
||||
own filesystem view (container, pod sidecar, or remote host), so paths
|
||||
it reports (e.g. skill ``package_root``) are NOT resolvable on the
|
||||
LangBot side. When False, Box runs as a stdio child process that shares
|
||||
LangBot's filesystem.
|
||||
own filesystem view (container, pod sidecar, or remote host), so only
|
||||
explicitly shared paths are usable on both sides. When False, Box runs
|
||||
as a stdio child process that shares LangBot's filesystem.
|
||||
"""
|
||||
return bool(
|
||||
self.configured_runtime_endpoint
|
||||
@@ -367,6 +366,12 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
def _ensure_control_token(self, *, allow_generate: bool) -> str:
|
||||
if not self._control_token and allow_generate:
|
||||
self._control_token = secrets.token_urlsafe(48)
|
||||
if not self._control_token:
|
||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||
raise BoxRuntimeUnavailableError(
|
||||
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime'
|
||||
)
|
||||
return ''
|
||||
try:
|
||||
self._control_token = validate_control_token(self._control_token)
|
||||
except ValueError as exc:
|
||||
@@ -376,19 +381,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
return self._control_token
|
||||
|
||||
def get_control_headers(self) -> dict[str, str]:
|
||||
"""Headers for the instance-authenticated RPC control handshake."""
|
||||
"""Return instance-scoped RPC headers and the optional shared secret."""
|
||||
|
||||
self._ensure_control_token(allow_generate=False)
|
||||
return {
|
||||
BOX_CONTROL_TOKEN_HEADER: self._control_token,
|
||||
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
|
||||
}
|
||||
headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
|
||||
if self._control_token:
|
||||
headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
|
||||
return headers
|
||||
|
||||
def get_relay_headers(
|
||||
self,
|
||||
action_context: ActionContext,
|
||||
) -> dict[str, str]:
|
||||
"""Return authenticated, placement-scoped relay handshake headers."""
|
||||
"""Return instance- and placement-scoped relay handshake headers."""
|
||||
|
||||
context = ActionContext.model_validate(action_context).without_installation()
|
||||
if context.instance_uuid != self._trusted_instance_uuid:
|
||||
|
||||
+70
-220
@@ -28,8 +28,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
BoxExecutionResult,
|
||||
BoxHostMountMode,
|
||||
BoxManagedProcessInfo,
|
||||
BoxManagedProcessSpec,
|
||||
BoxMountSpec,
|
||||
BoxProfile,
|
||||
BoxSpec,
|
||||
)
|
||||
@@ -169,7 +171,7 @@ class BoxService:
|
||||
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
||||
self.ap.logger.info(
|
||||
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
|
||||
'skill add/edit, stdio MCP) will be unavailable.'
|
||||
'stdio MCP, executable package scripts) will be unavailable.'
|
||||
)
|
||||
return
|
||||
try:
|
||||
@@ -275,10 +277,6 @@ class BoxService:
|
||||
await self._purge_attachment_dirs()
|
||||
self._available = True
|
||||
self._connector_error = ''
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
||||
if callable(reload_skills) and not self._cloud_managed:
|
||||
await reload_skills()
|
||||
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -358,19 +356,17 @@ class BoxService:
|
||||
"""Whether LangBot and the Box runtime share a filesystem view.
|
||||
|
||||
This is True only when Box runs as a local stdio child process of
|
||||
LangBot (same container/host). In that case paths the Box runtime
|
||||
reports — notably skill ``package_root`` — resolve identically on the
|
||||
LangBot side, so LangBot may validate them against its own filesystem.
|
||||
LangBot (same container/host). In that case host paths resolve
|
||||
identically on both sides and Core may perform local filesystem work.
|
||||
|
||||
It is False for every separated deployment (Docker Compose, k8s
|
||||
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
||||
where the Box runtime owns its own filesystem and LangBot must trust
|
||||
the paths it reports rather than checking them locally.
|
||||
where only explicitly shared and identically mounted roots can cross
|
||||
the process boundary.
|
||||
|
||||
When Box is wired up with an injected client (tests, custom embeds)
|
||||
there is no connector to introspect; we conservatively report False so
|
||||
LangBot never wrongly drops Box-reported skills. An explicit override
|
||||
can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
there is no connector to introspect; we conservatively report False.
|
||||
An explicit override can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
embedder that knows the real topology).
|
||||
"""
|
||||
if self._shares_filesystem_with_box_override is not None:
|
||||
@@ -455,7 +451,9 @@ class BoxService:
|
||||
|
||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||
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._admission is None:
|
||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||
@@ -481,11 +479,18 @@ class BoxService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
spec_payload: dict,
|
||||
*,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
|
||||
|
||||
payload = dict(spec_payload)
|
||||
trusted_mounts = self._normalize_trusted_read_only_mounts(trusted_read_only_mounts or [])
|
||||
if not self._cloud_managed:
|
||||
if trusted_mounts:
|
||||
if payload.get('extra_mounts'):
|
||||
raise BoxValidationError('extra_mounts and trusted_read_only_mounts cannot both be supplied')
|
||||
payload['extra_mounts'] = trusted_mounts
|
||||
return payload
|
||||
policy = self._admission_policy
|
||||
if policy is None:
|
||||
@@ -535,7 +540,7 @@ class BoxService:
|
||||
'network': 'off',
|
||||
'host_path': canonical_host_path,
|
||||
'mount_path': '/workspace',
|
||||
'extra_mounts': [],
|
||||
'extra_mounts': trusted_mounts,
|
||||
'persistent': True,
|
||||
'timeout_sec': min(timeout, policy.max_timeout_sec),
|
||||
'cpus': policy.cpus,
|
||||
@@ -547,6 +552,22 @@ class BoxService:
|
||||
)
|
||||
return payload
|
||||
|
||||
def _normalize_trusted_read_only_mounts(self, mounts: list[dict]) -> list[dict]:
|
||||
"""Validate Core-composed artifacts before crossing into Box."""
|
||||
|
||||
normalized: list[dict] = []
|
||||
for raw_mount in mounts:
|
||||
mount = BoxMountSpec.model_validate(raw_mount)
|
||||
if mount.mode != BoxHostMountMode.READ_ONLY:
|
||||
raise BoxAdmissionError('Core-composed additional mounts must be read-only')
|
||||
host_path = os.path.realpath(mount.host_path)
|
||||
if not os.path.isdir(host_path):
|
||||
raise BoxAdmissionError('Core-composed read-only mount source is unavailable')
|
||||
if not any(_is_path_under(host_path, root) for root in self.allowed_mount_roots):
|
||||
raise BoxAdmissionError('Core-composed read-only mount source is outside allowed_mount_roots')
|
||||
normalized.append(mount.model_copy(update={'host_path': host_path}).model_dump(mode='json'))
|
||||
return normalized
|
||||
|
||||
def _reject_cloud_managed_process(self) -> None:
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
|
||||
@@ -563,11 +584,18 @@ class BoxService:
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
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))
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
spec_payload = self._managed_policy_payload(
|
||||
execution_context,
|
||||
spec_payload,
|
||||
trusted_read_only_mounts=trusted_read_only_mounts,
|
||||
)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
@@ -654,70 +682,12 @@ class BoxService:
|
||||
variables.setdefault('global', 'global')
|
||||
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
||||
|
||||
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Build extra_mounts entries for all pipeline-bound skills.
|
||||
|
||||
This ensures that when a container is first created it already has
|
||||
all skill packages mounted, regardless of which skill is currently
|
||||
activated.
|
||||
|
||||
Path validation is filesystem-topology dependent. When LangBot and the
|
||||
Box runtime share a filesystem (local stdio mode), a skill whose
|
||||
``package_root`` is missing or no longer a directory is skipped with a
|
||||
warning instead of being passed through to the backend. Without that
|
||||
guard the three backends behave inconsistently on a stale mount: nsjail
|
||||
refuses to start the sandbox (failing every exec in the session),
|
||||
Docker silently auto-creates a root-owned empty directory on the host,
|
||||
and E2B silently skips the upload — none of which surfaces an
|
||||
actionable error.
|
||||
|
||||
When Box runs as a separate process (Docker Compose, k8s sidecar,
|
||||
``--standalone-box``, or a remote ``runtime.endpoint``), the
|
||||
``package_root`` reported by ``list_skills`` is the Box runtime's own
|
||||
filesystem path and is NOT resolvable on the LangBot side. Validating
|
||||
it locally would wrongly drop every skill, so LangBot trusts the path
|
||||
and lets the Box runtime resolve it. The Box runtime only ever reports
|
||||
skills it discovered on its own filesystem, so the path is valid there
|
||||
by construction.
|
||||
"""
|
||||
if self._cloud_managed:
|
||||
return []
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
from ..provider.tools.loaders import skill as skill_loader
|
||||
|
||||
validate_locally = self.shares_filesystem_with_box
|
||||
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
mounts: list[dict] = []
|
||||
for skill_name, skill_data in visible_skills.items():
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
continue
|
||||
if validate_locally and not os.path.isdir(package_root):
|
||||
self.ap.logger.warning(
|
||||
f'Skill "{skill_name}" package_root missing on filesystem '
|
||||
f'({package_root}); skipping mount to prevent sandbox failures. '
|
||||
f'The skill cache may be stale — consider reloading skills.'
|
||||
)
|
||||
continue
|
||||
mounts.append(
|
||||
{
|
||||
'host_path': package_root,
|
||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
||||
'mode': 'rw',
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skill_name: str | None = None,
|
||||
read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Execute an agent-facing ``exec`` tool call.
|
||||
|
||||
@@ -725,8 +695,6 @@ class BoxService:
|
||||
``BoxSpec.cmd`` field and injects the session id from the query.
|
||||
"""
|
||||
spec_payload: dict = {'cmd': parameters['command']}
|
||||
if skill_name is not None:
|
||||
spec_payload['skill_name'] = skill_name
|
||||
|
||||
# Pass through allowed agent-facing fields
|
||||
for key in ('workdir', 'timeout_sec', 'env'):
|
||||
@@ -736,11 +704,11 @@ class BoxService:
|
||||
# Inject context the agent must not control
|
||||
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
||||
|
||||
# Mount all pipeline-bound skills so they are available in the container
|
||||
if 'extra_mounts' not in spec_payload:
|
||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||
|
||||
return await self.execute_spec_payload(spec_payload, query)
|
||||
return await self.execute_spec_payload(
|
||||
spec_payload,
|
||||
query,
|
||||
trusted_read_only_mounts=read_only_mounts,
|
||||
)
|
||||
|
||||
async def execute_in_context(
|
||||
self,
|
||||
@@ -1210,8 +1178,9 @@ class BoxService:
|
||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||
|
||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
||||
only reliably transfers small files. The host path is preferred.
|
||||
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
||||
base64 payload can be far larger than the 4000-char display limit.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
@@ -1265,14 +1234,20 @@ class BoxService:
|
||||
' break\n'
|
||||
'print(json.dumps(out))\n'
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
spec_payload: dict = {
|
||||
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||
'timeout_sec': 120,
|
||||
'session_id': self.resolve_box_session_id(query),
|
||||
}
|
||||
try:
|
||||
spec = self.build_spec(spec_payload)
|
||||
result = await self.client.execute(spec)
|
||||
except Exception:
|
||||
return []
|
||||
if not result.ok:
|
||||
return []
|
||||
try:
|
||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
||||
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -1514,126 +1489,6 @@ class BoxService:
|
||||
self._runtime_connector.get_relay_headers(action_context),
|
||||
)
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skills(action_context=self._action_context(execution_context))
|
||||
|
||||
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed and str(payload.get('package_root', '') or '').strip():
|
||||
raise BoxAdmissionError('Cloud skill package_root is runtime-owned')
|
||||
if self._cloud_managed:
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
|
||||
|
||||
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed:
|
||||
# The runtime already owns the package path for an existing skill.
|
||||
# A serialized read response may contain it, but it is never an
|
||||
# authority-bearing update field in shared Cloud mode.
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.update_skill(
|
||||
name,
|
||||
payload,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
await self.client.delete_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud')
|
||||
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
|
||||
|
||||
async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
return execution_context
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skill_files(
|
||||
name,
|
||||
path,
|
||||
include_hidden,
|
||||
max_entries,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.read_skill_file(
|
||||
name,
|
||||
path,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.write_skill_file(
|
||||
name,
|
||||
path,
|
||||
content,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def preview_skill_zip(
|
||||
self,
|
||||
context: TenantContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.preview_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def install_skill_zip(
|
||||
self,
|
||||
context: TenantContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_paths: list[str] | None = None,
|
||||
source_path: str = '',
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.install_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
source_paths,
|
||||
source_path,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||
stderr, stderr_truncated = self._truncate(result.stderr)
|
||||
@@ -1770,14 +1625,6 @@ class BoxService:
|
||||
default_workspace = os.path.join(self.host_root, default_workspace)
|
||||
return os.path.realpath(os.path.abspath(default_workspace))
|
||||
|
||||
def get_skills_root(self) -> str | None:
|
||||
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
|
||||
if not skills_root:
|
||||
skills_root = 'skills'
|
||||
if not os.path.isabs(skills_root) and self.host_root is not None:
|
||||
skills_root = os.path.join(self.host_root, skills_root)
|
||||
return os.path.realpath(os.path.abspath(skills_root))
|
||||
|
||||
def _load_enabled(self) -> bool:
|
||||
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
||||
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
||||
@@ -2133,5 +1980,8 @@ class BoxService:
|
||||
if backend_name:
|
||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||
else:
|
||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
||||
payload['connector_error'] = (
|
||||
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||
'Trusted local development may explicitly select the unsafe host backend.'
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -1,43 +1,28 @@
|
||||
"""Reusable workspace/session helpers built on top of Box.
|
||||
|
||||
This module is the middle layer between the raw Box runtime primitives and
|
||||
application-specific flows such as skills or MCP stdio.
|
||||
This module is the middle layer between raw Box runtime primitives and
|
||||
application-specific consumers.
|
||||
|
||||
It intentionally stays generic:
|
||||
- path and virtualenv rewriting are workspace concerns
|
||||
- Python project detection/bootstrap are workspace concerns
|
||||
- session exec / managed-process helpers are workspace concerns
|
||||
|
||||
Higher layers add their own semantics on top, for example:
|
||||
- skills choose a stable per-skill session id and use repeated exec
|
||||
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
|
||||
Higher layers add their own semantics on top; BoxWorkspaceSession retains only
|
||||
workspace, execution, and managed-process concepts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from typing import Any
|
||||
|
||||
PYTHON_MANIFEST_FILES = (
|
||||
'requirements.txt',
|
||||
'pyproject.toml',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
)
|
||||
from ..utils.python_workspace import list_python_manifest_files
|
||||
|
||||
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
||||
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
|
||||
|
||||
|
||||
def normalize_host_path(path: str | None) -> str:
|
||||
if path is None:
|
||||
return ''
|
||||
stripped = str(path).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(stripped))
|
||||
|
||||
|
||||
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
||||
"""Translate a host path into the path visible inside the sandbox mount."""
|
||||
if not host_path or not path:
|
||||
@@ -98,13 +83,6 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
|
||||
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
|
||||
|
||||
|
||||
def list_python_manifest_files(host_path: str | None) -> list[str]:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return []
|
||||
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
|
||||
|
||||
|
||||
def classify_python_workspace(host_path: str | None) -> str | None:
|
||||
"""Return the generic Python workspace shape, without app-specific policy."""
|
||||
manifest_files = set(list_python_manifest_files(host_path))
|
||||
@@ -117,163 +95,6 @@ def classify_python_workspace(host_path: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def should_prepare_python_env(host_path: str | None) -> bool:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return False
|
||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||
return True
|
||||
return bool(list_python_manifest_files(normalized_root))
|
||||
|
||||
|
||||
def wrap_python_command_with_env(
|
||||
command: str,
|
||||
*,
|
||||
mount_path: str = '/workspace',
|
||||
state_path: str | None = None,
|
||||
) -> str:
|
||||
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
|
||||
|
||||
``mount_path`` is always the source tree used for manifest hashing and
|
||||
installation. ``state_path`` may point at a separate writable directory
|
||||
for read-only source mounts; when omitted, legacy mutable-workspace behavior
|
||||
stores the environment beside the source.
|
||||
"""
|
||||
writable_state_path = state_path or mount_path
|
||||
bootstrap = textwrap.dedent(
|
||||
f"""
|
||||
set -e
|
||||
|
||||
_LB_VENV_DIR="{writable_state_path}/.venv"
|
||||
_LB_META_DIR="{writable_state_path}/.langbot"
|
||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||
_LB_TMP_DIR="{writable_state_path}/.tmp"
|
||||
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {{
|
||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = "{mount_path}"
|
||||
max_manifest_bytes = 10 * 1024 * 1024
|
||||
digest = hashlib.sha256()
|
||||
manifest_files = []
|
||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if os.path.getsize(path) > max_manifest_bytes:
|
||||
raise RuntimeError(
|
||||
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
|
||||
)
|
||||
manifest_files.append(rel)
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\\0")
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
digest.update(b"\\0")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"python_executable": sys.executable,
|
||||
"python_version": list(sys.version_info[:3]),
|
||||
"manifest_files": manifest_files,
|
||||
"manifest_sha256": digest.hexdigest(),
|
||||
}},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
PY
|
||||
}}
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
|
||||
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
|
||||
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
||||
. "$_LB_VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||
python -m pip install -r "{mount_path}/requirements.txt"
|
||||
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
|
||||
python -m pip install "{mount_path}"
|
||||
fi
|
||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||
{command}
|
||||
"""
|
||||
).strip()
|
||||
return bootstrap + '\n'
|
||||
|
||||
|
||||
class BoxWorkspaceSession:
|
||||
"""High-level handle for one reusable workspace-backed Box session.
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ from typing import Any, Protocol, runtime_checkable
|
||||
from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||
from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
|
||||
from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
|
||||
from .model_catalog import CloudModelCatalogProvider
|
||||
|
||||
|
||||
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
|
||||
REQUIRED_TENANT_ISOLATION_VERSION = 2
|
||||
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
|
||||
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536, 3072})
|
||||
|
||||
|
||||
class CloudBootstrapError(RuntimeError):
|
||||
@@ -50,6 +51,7 @@ class OpenSourceDeployment:
|
||||
)
|
||||
directory_provider: None = None
|
||||
manifest_provider: None = None
|
||||
model_catalog_provider: None = None
|
||||
persistence_mode: str = 'oss_compat'
|
||||
required_vector_backend: str | None = None
|
||||
|
||||
@@ -80,6 +82,7 @@ class VerifiedCloudDeployment:
|
||||
entitlement_provider: EntitlementProvider
|
||||
directory_provider: DirectoryProjectionProvider
|
||||
manifest_provider: CloudManifestProvider
|
||||
model_catalog_provider: CloudModelCatalogProvider
|
||||
verification_key_id: str
|
||||
mode: str = dataclasses.field(default='cloud', init=False)
|
||||
workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
|
||||
@@ -110,6 +113,8 @@ class VerifiedCloudDeployment:
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
|
||||
if not isinstance(self.manifest_provider, CloudManifestProvider):
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
|
||||
if not isinstance(self.model_catalog_provider, CloudModelCatalogProvider):
|
||||
raise CloudBootstrapError('Verified Cloud bootstrap did not provide a model catalog adapter')
|
||||
|
||||
def validate_instance_config(self, config: dict[str, Any]) -> None:
|
||||
try:
|
||||
@@ -138,8 +143,14 @@ class VerifiedCloudDeployment:
|
||||
if plugin_worker.get('require_hard_limits') is not True:
|
||||
raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
|
||||
box_config = config.get('box', {})
|
||||
if box_config.get('enabled') is not True:
|
||||
raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
|
||||
box_enabled = box_config.get('enabled')
|
||||
if box_enabled is False:
|
||||
# Explicitly disabling Box removes the sandbox surface entirely and
|
||||
# therefore does not weaken tenant isolation. Validate the strict
|
||||
# runtime/admission contract only when the surface is enabled.
|
||||
return
|
||||
if box_enabled is not True:
|
||||
raise CloudBootstrapError('Cloud runtime requires box.enabled to be an explicit boolean')
|
||||
if box_config.get('backend') != 'nsjail':
|
||||
raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
|
||||
runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, Direc
|
||||
from ..entity.persistence.user import AccountSource, AccountStatus, User
|
||||
from ..entity.persistence.workspace import (
|
||||
MembershipRole,
|
||||
MembershipSource,
|
||||
MembershipStatus,
|
||||
Workspace,
|
||||
WorkspaceExecutionSource,
|
||||
@@ -124,10 +125,21 @@ class DirectoryProjectionService:
|
||||
# The database cursor remains the shared projection high-water mark,
|
||||
# while this cursor tracks what this process has actually observed.
|
||||
self._consumer_cursor: int | None = None
|
||||
self._sync_lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def refresh_snapshot(self) -> None:
|
||||
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def _refresh_snapshot(self) -> None:
|
||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||
for _attempt in range(5):
|
||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||
@@ -158,9 +170,84 @@ class DirectoryProjectionService:
|
||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||
|
||||
async def sync_once(self) -> None:
|
||||
async with self._sync_lock:
|
||||
await self._sync_once()
|
||||
|
||||
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
|
||||
"""Synchronously project an exact Workspace set without moving the event cursor."""
|
||||
|
||||
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
|
||||
if not requested:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
|
||||
if len(requested) > self.event_limit:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
|
||||
async with self._sync_lock:
|
||||
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
|
||||
await self._apply_targeted_delta(delta, requested)
|
||||
|
||||
async def _apply_targeted_delta(
|
||||
self,
|
||||
delta: DirectoryDelta,
|
||||
requested_workspace_uuids: tuple[str, ...],
|
||||
) -> None:
|
||||
if not isinstance(delta, DirectoryDelta):
|
||||
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
|
||||
workspace_count, membership_count = self._validate_batch_capacity(
|
||||
delta.workspaces,
|
||||
full_snapshot=False,
|
||||
)
|
||||
delta = DirectoryDelta.model_validate(delta.model_dump())
|
||||
if delta.instance_uuid != self.instance_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
|
||||
requested = set(requested_workspace_uuids)
|
||||
if set(delta.requested_workspace_uuids) != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
|
||||
if {workspace.uuid for workspace in delta.workspaces} != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
|
||||
|
||||
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
|
||||
if not callable(directory_uow):
|
||||
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
|
||||
|
||||
async with directory_uow(self.instance_uuid) as uow:
|
||||
session = uow.session
|
||||
state = await session.scalar(
|
||||
sqlalchemy.select(DirectoryProjectionState)
|
||||
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if state is None:
|
||||
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
|
||||
snapshot = DirectorySnapshot(
|
||||
instance_uuid=self.instance_uuid,
|
||||
cursor=state.cursor,
|
||||
generated_at=delta.generated_at,
|
||||
workspaces=delta.workspaces,
|
||||
)
|
||||
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
|
||||
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
|
||||
active_workspace_count = await self._enforce_active_workspace_capacity(session)
|
||||
await session.flush()
|
||||
|
||||
await self._update_entitlement_workspace_activity(
|
||||
snapshot.workspaces,
|
||||
requested_workspace_uuids=requested,
|
||||
)
|
||||
self._publish_runtime_execution_projection(
|
||||
snapshot.workspaces,
|
||||
affected_workspace_uuids=requested,
|
||||
)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
memberships=membership_count,
|
||||
)
|
||||
|
||||
async def _sync_once(self) -> None:
|
||||
cursor = self._consumer_cursor
|
||||
if cursor is None:
|
||||
await self.initialize()
|
||||
await self._refresh_snapshot()
|
||||
return
|
||||
batch = await self.provider.fetch_events(
|
||||
self.instance_uuid,
|
||||
@@ -358,6 +445,7 @@ class DirectoryProjectionService:
|
||||
|
||||
await self._reconcile_entitlement_snapshot_set(snapshot)
|
||||
self._publish_runtime_execution_projection(snapshot.workspaces)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
@@ -466,6 +554,7 @@ class DirectoryProjectionService:
|
||||
returned.values(),
|
||||
affected_workspace_uuids=requested,
|
||||
)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
@@ -475,6 +564,14 @@ class DirectoryProjectionService:
|
||||
self._record_success()
|
||||
self._consumer_cursor = batch.cursor
|
||||
|
||||
def _request_model_catalog_sync(self) -> None:
|
||||
"""Wake model provisioning after a committed directory change."""
|
||||
|
||||
service = getattr(self.ap, 'cloud_model_catalog_service', None)
|
||||
request_sync = getattr(service, 'request_sync', None)
|
||||
if callable(request_sync):
|
||||
request_sync()
|
||||
|
||||
def _publish_runtime_execution_projection(
|
||||
self,
|
||||
workspaces: Iterable[DirectoryWorkspace],
|
||||
@@ -697,7 +794,13 @@ class DirectoryProjectionService:
|
||||
for row in inbox_rows:
|
||||
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] = {}
|
||||
emails: dict[str, str] = {}
|
||||
for workspace in snapshot.workspaces:
|
||||
@@ -762,6 +865,12 @@ class DirectoryProjectionService:
|
||||
continue
|
||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
||||
if preserve_existing:
|
||||
# A targeted Workspace fetch has no independently monotonic
|
||||
# Account revision. It may create a missing runtime shadow, but
|
||||
# ordered event/snapshot projection remains the only updater of
|
||||
# existing Account identity and status fields.
|
||||
continue
|
||||
if account.projection_revision > snapshot.cursor:
|
||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||
projected_account = self._account_projection(member)
|
||||
@@ -876,15 +985,15 @@ class DirectoryProjectionService:
|
||||
account_uuid=member.account_uuid,
|
||||
role=role,
|
||||
status=status,
|
||||
source=MembershipSource.CLOUD_PROJECTION.value,
|
||||
joined_at=joined_at,
|
||||
projection_revision=member.projection_revision,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if membership.projection_revision == 0:
|
||||
# Revision zero is Core-owned collaboration state. Directory
|
||||
# projection seeds memberships, but must not overwrite later
|
||||
# invitation, role, or removal decisions made by Core.
|
||||
if membership.source != MembershipSource.CLOUD_PROJECTION.value:
|
||||
# Core-owned collaboration state is never adopted based on
|
||||
# account provenance, revision, or matching account identity.
|
||||
continue
|
||||
if membership.uuid != member.membership_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
|
||||
@@ -896,11 +1005,12 @@ class DirectoryProjectionService:
|
||||
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
|
||||
membership.role = role
|
||||
membership.status = status
|
||||
membership.source = MembershipSource.CLOUD_PROJECTION.value
|
||||
membership.joined_at = joined_at
|
||||
membership.projection_revision = member.projection_revision
|
||||
|
||||
for account_uuid, membership in existing.items():
|
||||
if account_uuid not in included_accounts and membership.projection_revision != 0:
|
||||
if account_uuid not in included_accounts and membership.source == MembershipSource.CLOUD_PROJECTION.value:
|
||||
membership.status = MembershipStatus.REMOVED.value
|
||||
membership.projection_revision = max(
|
||||
int(membership.projection_revision),
|
||||
|
||||
@@ -15,12 +15,15 @@ from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core.app import Application
|
||||
|
||||
|
||||
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
|
||||
LAUNCH_KIND = 'workspace.launch'
|
||||
SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch'
|
||||
EXPECTED_ISSUER = 'langbot-space'
|
||||
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
|
||||
_CONSUMED_JTI_MAX_ENTRIES = 4096
|
||||
@@ -123,15 +126,60 @@ class SpaceLaunchService:
|
||||
payload = claims.get('payload')
|
||||
if not isinstance(payload, dict):
|
||||
raise SpaceLaunchError('Launch assertion payload must be a JSON object')
|
||||
account_uuid = _required_string(payload, 'account_uuid')
|
||||
kind = _required_string(claims, 'kind')
|
||||
workspace_uuid = _required_string(payload, 'workspace_uuid')
|
||||
if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
|
||||
raise SpaceLaunchError('Launch assertion targets another Workspace')
|
||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||
return {
|
||||
if kind == SUPPORT_ADMIN_LAUNCH_KIND:
|
||||
if 'account_uuid' in payload:
|
||||
raise SpaceLaunchError('Admin launch assertion must not identify a customer Account')
|
||||
if payload.get('launch_mode') != 'support_admin' or payload.get('principal_type') != 'support_admin':
|
||||
raise SpaceLaunchError('Admin launch principal must be support_admin')
|
||||
actor_account_uuid = _required_string(payload, 'actor_account_uuid')
|
||||
if _required_string(payload, 'effective_role') != 'owner':
|
||||
raise SpaceLaunchError('Admin launch effective role must be owner')
|
||||
issued_at = _required_int(claims, 'iat')
|
||||
expires_at = _required_int(claims, 'exp', minimum=1)
|
||||
if expires_at - issued_at > 90:
|
||||
raise SpaceLaunchError('Admin launch assertion lifetime exceeds 90 seconds')
|
||||
grant_jti_hash = hash_grant_jti(_required_string(claims, 'jti'))
|
||||
result = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'launch_mode': 'support_admin',
|
||||
'actor_account_uuid': actor_account_uuid,
|
||||
'effective_role': 'owner',
|
||||
'grant_jti_hash': grant_jti_hash,
|
||||
}
|
||||
support_service = getattr(self.ap, 'support_admin_session_service', None)
|
||||
if support_service is None or not callable(getattr(support_service, 'consume_launch_grant', None)):
|
||||
raise SpaceLaunchError('Durable support admin session service is unavailable')
|
||||
try:
|
||||
support_session = await support_service.consume_launch_grant(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
)
|
||||
except SupportAdminReplayError as exc:
|
||||
raise SpaceLaunchError('Launch assertion has already been consumed') from exc
|
||||
except SupportAdminSessionError as exc:
|
||||
raise SpaceLaunchError(str(exc)) from exc
|
||||
result['support_admin_token'] = support_session.token
|
||||
self.ap.logger.info(
|
||||
'cloud_support_admin_launch_consumed actor_account_uuid=%s workspace_uuid=%s',
|
||||
result['actor_account_uuid'],
|
||||
workspace_uuid,
|
||||
)
|
||||
return result
|
||||
|
||||
if payload.get('launch_mode') is not None:
|
||||
raise SpaceLaunchError('Launch assertion mode is unsupported')
|
||||
account_uuid = _required_string(payload, 'account_uuid')
|
||||
result = {
|
||||
'account_uuid': account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
}
|
||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||
return result
|
||||
|
||||
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
@@ -169,8 +217,9 @@ class SpaceLaunchService:
|
||||
raise SpaceLaunchError('Launch assertion subject targets another instance')
|
||||
if _required_string(claims, 'instance_uuid') != instance_uuid:
|
||||
raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
|
||||
if _required_string(claims, 'kind') != LAUNCH_KIND:
|
||||
raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
|
||||
kind = _required_string(claims, 'kind')
|
||||
if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}:
|
||||
raise SpaceLaunchError('Launch assertion kind is not supported')
|
||||
|
||||
issued_at = _required_int(claims, 'iat')
|
||||
not_before = _required_int(claims, 'nbf')
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
import sqlalchemy
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
||||
|
||||
from ..entity.persistence import model as persistence_model
|
||||
|
||||
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
|
||||
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
|
||||
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
|
||||
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
|
||||
_MODEL_TABLES = (
|
||||
persistence_model.LLMModel,
|
||||
persistence_model.EmbeddingModel,
|
||||
persistence_model.RerankModel,
|
||||
)
|
||||
|
||||
|
||||
class CloudModelCatalogItem(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
||||
|
||||
uuid: str = Field(min_length=1, max_length=255)
|
||||
model_id: str = Field(min_length=1, max_length=255)
|
||||
category: Literal['chat', 'embedding', 'rerank']
|
||||
llm_abilities: tuple[str, ...] = ()
|
||||
is_featured: bool = False
|
||||
featured_order: int = 0
|
||||
|
||||
@field_validator('llm_abilities', mode='before')
|
||||
@classmethod
|
||||
def normalize_missing_abilities(cls, value: Any) -> Any:
|
||||
return () if value is None else value
|
||||
|
||||
@field_validator('llm_abilities')
|
||||
@classmethod
|
||||
def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if any(not item.strip() or len(item) > 64 for item in value):
|
||||
raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError('Model abilities must be unique')
|
||||
return value
|
||||
|
||||
|
||||
class CloudWorkspaceModelBilling(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
||||
|
||||
workspace_uuid: str = Field(min_length=36, max_length=36)
|
||||
owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
|
||||
api_key: SecretStr | None = None
|
||||
credits: int | None = None
|
||||
|
||||
@field_validator('workspace_uuid')
|
||||
@classmethod
|
||||
def validate_uuid(cls, value: str) -> str:
|
||||
return str(uuid.UUID(value))
|
||||
|
||||
@field_validator('owner_account_uuid')
|
||||
@classmethod
|
||||
def validate_optional_uuid(cls, value: str | None) -> str | None:
|
||||
return None if value is None else str(uuid.UUID(value))
|
||||
|
||||
|
||||
class CloudModelCatalogSnapshot(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', frozen=True)
|
||||
|
||||
instance_uuid: str = Field(min_length=1, max_length=255)
|
||||
generated_at: datetime
|
||||
base_url: str = Field(min_length=1, max_length=512)
|
||||
models: tuple[CloudModelCatalogItem, ...]
|
||||
workspaces: tuple[CloudWorkspaceModelBilling, ...]
|
||||
|
||||
@field_validator('base_url')
|
||||
@classmethod
|
||||
def validate_base_url(cls, value: str) -> str:
|
||||
normalized = value.rstrip('/')
|
||||
if not normalized.startswith('https://'):
|
||||
raise ValueError('Cloud model gateway base URL must use HTTPS')
|
||||
return normalized
|
||||
|
||||
@field_validator('models')
|
||||
@classmethod
|
||||
def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
|
||||
if len(value) > 500:
|
||||
raise ValueError('Cloud model catalog exceeds 500 models')
|
||||
identities = {(item.category, item.uuid) for item in value}
|
||||
if len(identities) != len(value):
|
||||
raise ValueError('Cloud model catalog contains duplicate model identities')
|
||||
return value
|
||||
|
||||
@field_validator('workspaces')
|
||||
@classmethod
|
||||
def validate_workspaces(
|
||||
cls, value: tuple[CloudWorkspaceModelBilling, ...]
|
||||
) -> tuple[CloudWorkspaceModelBilling, ...]:
|
||||
if len(value) > 10_000:
|
||||
raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
|
||||
identities = {item.workspace_uuid for item in value}
|
||||
if len(identities) != len(value):
|
||||
raise ValueError('Cloud model catalog contains duplicate Workspaces')
|
||||
return value
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CloudModelCatalogProvider(Protocol):
|
||||
async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
|
||||
"""Fetch and verify the complete model catalog and Workspace billing projection."""
|
||||
...
|
||||
|
||||
|
||||
def system_provider_uuid(workspace_uuid: str) -> str:
|
||||
workspace = str(uuid.UUID(workspace_uuid))
|
||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))
|
||||
|
||||
|
||||
def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
|
||||
workspace = str(uuid.UUID(workspace_uuid))
|
||||
if category not in _SUPPORTED_CATEGORIES:
|
||||
raise ValueError(f'Unsupported model category: {category}')
|
||||
if not upstream_uuid:
|
||||
raise ValueError('Upstream model UUID is required')
|
||||
return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))
|
||||
|
||||
|
||||
class CloudModelCatalogSyncService:
|
||||
"""Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: Any,
|
||||
provider: CloudModelCatalogProvider,
|
||||
instance_uuid: str,
|
||||
*,
|
||||
sync_interval_seconds: float = 3600.0,
|
||||
) -> None:
|
||||
if not isinstance(provider, CloudModelCatalogProvider):
|
||||
raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
|
||||
if sync_interval_seconds < 10:
|
||||
raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
|
||||
self.ap = ap
|
||||
self.provider = provider
|
||||
self.instance_uuid = instance_uuid
|
||||
self.sync_interval_seconds = float(sync_interval_seconds)
|
||||
# A tenant UoW commits one Workspace at a time. Keep a durable in-memory
|
||||
# convergence marker so a failed runtime reload is retried even when the
|
||||
# following database reconciliation is a no-op.
|
||||
self._runtime_reload_pending = False
|
||||
self._workspace_credits: dict[str, int | None] = {}
|
||||
self._sync_requested = asyncio.Event()
|
||||
|
||||
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
|
||||
"""Return the latest signed owner-credit projection for a Workspace."""
|
||||
return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self.sync_once(reload_runtime=False)
|
||||
|
||||
def request_sync(self) -> None:
|
||||
"""Wake the catalog loop after a directory Workspace change."""
|
||||
|
||||
self._sync_requested.set()
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
|
||||
except TimeoutError:
|
||||
pass
|
||||
self._sync_requested.clear()
|
||||
try:
|
||||
await self.sync_once(reload_runtime=True)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# Exception messages can contain rendered SQL bound values,
|
||||
# including provider API keys. Log only the exception class.
|
||||
self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})')
|
||||
|
||||
async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
|
||||
summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
|
||||
snapshot: CloudModelCatalogSnapshot | None = None
|
||||
sync_error: Exception | None = None
|
||||
reload_error: Exception | None = None
|
||||
try:
|
||||
snapshot = await self.provider.fetch_model_catalog(self.instance_uuid)
|
||||
if snapshot.instance_uuid != self.instance_uuid:
|
||||
raise ValueError('Cloud model catalog targets another LangBot instance')
|
||||
|
||||
bindings = await self.ap.workspace_service.list_active_execution_bindings()
|
||||
billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces}
|
||||
missing = sorted(
|
||||
binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace
|
||||
)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces'
|
||||
)
|
||||
|
||||
for binding in bindings:
|
||||
counts = await self._sync_workspace(
|
||||
binding.workspace_uuid,
|
||||
snapshot,
|
||||
billing_by_workspace[binding.workspace_uuid],
|
||||
)
|
||||
summary['workspaces'] += 1
|
||||
workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted'))
|
||||
if workspace_changed:
|
||||
# _sync_workspace returns only after its tenant UoW commits.
|
||||
self._runtime_reload_pending = True
|
||||
for key in ('created', 'updated', 'deleted'):
|
||||
summary[key] += counts[key]
|
||||
self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
|
||||
except Exception as exc:
|
||||
sync_error = exc
|
||||
finally:
|
||||
model_mgr = getattr(self.ap, 'model_mgr', None)
|
||||
if reload_runtime and self._runtime_reload_pending and model_mgr is not None:
|
||||
try:
|
||||
await model_mgr.load_models_from_db()
|
||||
except Exception as exc:
|
||||
reload_error = exc
|
||||
else:
|
||||
self._runtime_reload_pending = False
|
||||
|
||||
if sync_error is not None:
|
||||
if reload_error is not None:
|
||||
raise sync_error from reload_error
|
||||
raise sync_error
|
||||
if reload_error is not None:
|
||||
raise reload_error
|
||||
|
||||
changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted'))
|
||||
if changed and snapshot is not None:
|
||||
self.ap.logger.info(
|
||||
'Cloud model catalog synchronized '
|
||||
f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, '
|
||||
f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})'
|
||||
)
|
||||
return summary
|
||||
|
||||
async def _sync_workspace(
|
||||
self,
|
||||
workspace_uuid: str,
|
||||
snapshot: CloudModelCatalogSnapshot,
|
||||
billing: CloudWorkspaceModelBilling,
|
||||
) -> dict[str, int]:
|
||||
counts = {'created': 0, 'updated': 0, 'deleted': 0}
|
||||
provider_uuid = system_provider_uuid(workspace_uuid)
|
||||
desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else []
|
||||
|
||||
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
||||
provider = await uow.session.scalar(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
)
|
||||
)
|
||||
provider_values = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'name': LANGBOT_MODELS_PROVIDER_NAME,
|
||||
'requester': LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
'base_url': snapshot.base_url,
|
||||
'api_keys': desired_keys,
|
||||
}
|
||||
if provider is None:
|
||||
provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values)
|
||||
uow.session.add(provider)
|
||||
await uow.session.flush()
|
||||
counts['created'] += 1
|
||||
elif self._update_entity(provider, provider_values):
|
||||
counts['updated'] += 1
|
||||
|
||||
existing_by_table: dict[type, dict[str, Any]] = {}
|
||||
for table in _MODEL_TABLES:
|
||||
rows = (
|
||||
await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid))
|
||||
).all()
|
||||
existing_by_table[table] = {row.uuid: row for row in rows}
|
||||
|
||||
desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES}
|
||||
for item in snapshot.models:
|
||||
table, values = self._model_values(workspace_uuid, provider_uuid, item)
|
||||
model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid)
|
||||
desired_ids[table].add(model_uuid)
|
||||
existing = existing_by_table[table].get(model_uuid)
|
||||
if existing is None:
|
||||
uow.session.add(table(uuid=model_uuid, **values))
|
||||
counts['created'] += 1
|
||||
elif self._update_entity(existing, values):
|
||||
counts['updated'] += 1
|
||||
|
||||
for table, entities in existing_by_table.items():
|
||||
for model_uuid, entity in entities.items():
|
||||
if model_uuid not in desired_ids[table]:
|
||||
await uow.session.delete(entity)
|
||||
counts['deleted'] += 1
|
||||
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _update_entity(entity: Any, values: dict[str, Any]) -> bool:
|
||||
changed = False
|
||||
for key, value in values.items():
|
||||
if getattr(entity, key) != value:
|
||||
setattr(entity, key, value)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _model_values(
|
||||
workspace_uuid: str,
|
||||
provider_uuid: str,
|
||||
item: CloudModelCatalogItem,
|
||||
) -> tuple[type, dict[str, Any]]:
|
||||
ranking = 100 - item.featured_order if item.is_featured else 0
|
||||
common = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'name': item.model_id,
|
||||
'provider_uuid': provider_uuid,
|
||||
'extra_args': {},
|
||||
'prefered_ranking': ranking,
|
||||
}
|
||||
if item.category == 'chat':
|
||||
return persistence_model.LLMModel, {
|
||||
**common,
|
||||
'abilities': list(item.llm_abilities),
|
||||
'context_length': None,
|
||||
}
|
||||
if item.category == 'embedding':
|
||||
return persistence_model.EmbeddingModel, common
|
||||
if item.category == 'rerank':
|
||||
return persistence_model.RerankModel, common
|
||||
raise ValueError(f'Unsupported model category: {item.category}')
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
|
||||
import jwt
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..entity.persistence.support_admin import SupportAdminTemporarySession
|
||||
from ..workspace.errors import WorkspaceError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core.app import Application
|
||||
|
||||
|
||||
SUPPORT_ADMIN_TOKEN_TYP = 'langbot-support-admin+jwt'
|
||||
SUPPORT_ADMIN_TOKEN_KIND = 'support_admin.session'
|
||||
SUPPORT_ADMIN_EFFECTIVE_ROLE = 'owner'
|
||||
SUPPORT_ADMIN_MAX_TOKEN_SECONDS = 300
|
||||
_SHA256_HEX = re.compile(r'^[0-9a-f]{64}$')
|
||||
|
||||
|
||||
class SupportAdminSessionError(ValueError):
|
||||
"""Raised when a support-admin session or token is not admissible."""
|
||||
|
||||
|
||||
class SupportAdminReplayError(SupportAdminSessionError):
|
||||
"""Raised when a launch grant JTI has already been consumed."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class IssuedSupportAdminSession:
|
||||
token: str
|
||||
grant_jti_hash: str
|
||||
workspace_uuid: str
|
||||
actor_account_uuid: str
|
||||
issued_at: datetime.datetime
|
||||
expires_at: datetime.datetime
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class SupportAdminSessionIdentity:
|
||||
grant_jti_hash: str
|
||||
workspace_uuid: str
|
||||
actor_account_uuid: str
|
||||
instance_uuid: str
|
||||
placement_generation: int
|
||||
|
||||
|
||||
def hash_grant_jti(jti: str) -> str:
|
||||
return hashlib.sha256(jti.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class SupportAdminSessionService:
|
||||
"""Issue and validate temporary Workspace-scoped support-admin sessions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: Application,
|
||||
*,
|
||||
wall_time: typing.Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.ap = ap
|
||||
self._wall_time = wall_time
|
||||
|
||||
async def consume_launch_grant(
|
||||
self,
|
||||
*,
|
||||
grant_jti_hash: str,
|
||||
workspace_uuid: str,
|
||||
actor_account_uuid: str,
|
||||
) -> IssuedSupportAdminSession:
|
||||
self._validate_grant_hash(grant_jti_hash)
|
||||
if not workspace_uuid or not actor_account_uuid:
|
||||
raise SupportAdminSessionError('Support admin session requires an actor and Workspace')
|
||||
|
||||
issued_at = self._utcnow()
|
||||
expires_at = issued_at + datetime.timedelta(seconds=SUPPORT_ADMIN_MAX_TOKEN_SECONDS)
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if not callable(tenant_uow):
|
||||
raise SupportAdminSessionError('Support admin sessions require tenant persistence')
|
||||
|
||||
try:
|
||||
async with tenant_uow(workspace_uuid) as uow:
|
||||
await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session)
|
||||
uow.session.add(
|
||||
SupportAdminTemporarySession(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await uow.session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise SupportAdminReplayError('Launch assertion has already been consumed') from exc
|
||||
except WorkspaceError as exc:
|
||||
raise SupportAdminSessionError('Workspace is unavailable for support access') from exc
|
||||
|
||||
return IssuedSupportAdminSession(
|
||||
token=self._encode_token(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
),
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def is_support_admin_token(self, token: str) -> bool:
|
||||
"""Return True only for compact JWTs marked as support-admin tokens."""
|
||||
|
||||
if not isinstance(token, str) or token.count('.') != 2:
|
||||
return False
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError:
|
||||
return False
|
||||
if header.get('typ') == SUPPORT_ADMIN_TOKEN_TYP:
|
||||
return True
|
||||
try:
|
||||
payload = jwt.decode(token, options={'verify_signature': False})
|
||||
except jwt.PyJWTError:
|
||||
return False
|
||||
return payload.get('kind') == SUPPORT_ADMIN_TOKEN_KIND
|
||||
|
||||
async def authenticate_token(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
requested_workspace_uuid: str | None,
|
||||
) -> SupportAdminSessionIdentity:
|
||||
if not self.is_support_admin_token(token):
|
||||
raise SupportAdminSessionError('Not a support admin token')
|
||||
workspace_uuid = (requested_workspace_uuid or '').strip()
|
||||
if not workspace_uuid:
|
||||
raise SupportAdminSessionError('Support admin token requires an explicit Workspace selector')
|
||||
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=['HS256'],
|
||||
issuer='langbot-core',
|
||||
audience=self._audience(workspace_uuid),
|
||||
options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud']},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise SupportAdminSessionError('Invalid support admin token') from exc
|
||||
self._validate_payload(payload, workspace_uuid)
|
||||
grant_jti_hash = payload['grant_jti_hash']
|
||||
actor_account_uuid = payload['actor_account_uuid']
|
||||
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if not callable(tenant_uow):
|
||||
raise SupportAdminSessionError('Support admin sessions require tenant persistence')
|
||||
|
||||
now = self._utcnow()
|
||||
async with tenant_uow(workspace_uuid) as uow:
|
||||
session = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash)
|
||||
if (
|
||||
session is None
|
||||
or session.workspace_uuid != workspace_uuid
|
||||
or session.actor_account_uuid != actor_account_uuid
|
||||
or session.revoked_at is not None
|
||||
or session.expires_at <= now
|
||||
):
|
||||
raise SupportAdminSessionError('Support admin session is inactive')
|
||||
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session)
|
||||
session.last_used_at = now
|
||||
await uow.session.flush()
|
||||
|
||||
return SupportAdminSessionIdentity(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
instance_uuid=binding.instance_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
|
||||
async def revoke_session(self, grant_jti_hash: str, workspace_uuid: str) -> None:
|
||||
self._validate_grant_hash(grant_jti_hash)
|
||||
now = self._utcnow()
|
||||
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
||||
row = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash)
|
||||
if row is not None and row.revoked_at is None:
|
||||
row.revoked_at = now
|
||||
|
||||
def _encode_token(
|
||||
self,
|
||||
*,
|
||||
grant_jti_hash: str,
|
||||
workspace_uuid: str,
|
||||
actor_account_uuid: str,
|
||||
issued_at: datetime.datetime,
|
||||
expires_at: datetime.datetime,
|
||||
) -> str:
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
payload: dict[str, typing.Any] = {
|
||||
'kind': SUPPORT_ADMIN_TOKEN_KIND,
|
||||
'iss': 'langbot-core',
|
||||
'aud': self._audience(workspace_uuid),
|
||||
'sub': f'support-admin:{actor_account_uuid}',
|
||||
'iat': issued_at,
|
||||
'nbf': issued_at,
|
||||
'exp': expires_at,
|
||||
'actor_account_uuid': actor_account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'effective_role': SUPPORT_ADMIN_EFFECTIVE_ROLE,
|
||||
'grant_jti_hash': grant_jti_hash,
|
||||
}
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256', headers={'typ': SUPPORT_ADMIN_TOKEN_TYP})
|
||||
|
||||
def _validate_payload(self, payload: dict[str, typing.Any], workspace_uuid: str) -> None:
|
||||
if payload.get('kind') != SUPPORT_ADMIN_TOKEN_KIND:
|
||||
raise SupportAdminSessionError('Invalid support admin token kind')
|
||||
if payload.get('workspace_uuid') != workspace_uuid:
|
||||
raise SupportAdminSessionError('Support admin session is scoped to another Workspace')
|
||||
if payload.get('effective_role') != SUPPORT_ADMIN_EFFECTIVE_ROLE:
|
||||
raise SupportAdminSessionError('Invalid support admin token role')
|
||||
actor_account_uuid = payload.get('actor_account_uuid')
|
||||
if not isinstance(actor_account_uuid, str) or not actor_account_uuid.strip():
|
||||
raise SupportAdminSessionError('Invalid support admin actor')
|
||||
grant_jti_hash = payload.get('grant_jti_hash')
|
||||
if not isinstance(grant_jti_hash, str) or not _SHA256_HEX.match(grant_jti_hash):
|
||||
raise SupportAdminSessionError('Invalid support admin grant')
|
||||
|
||||
def _audience(self, workspace_uuid: str) -> str:
|
||||
return f'langbot-support-admin:{self.ap.workspace_service.instance_uuid}:{workspace_uuid}'
|
||||
|
||||
@staticmethod
|
||||
def _validate_grant_hash(grant_jti_hash: str) -> None:
|
||||
if not _SHA256_HEX.match(grant_jti_hash):
|
||||
raise SupportAdminSessionError('Invalid support admin grant')
|
||||
|
||||
def _utcnow(self) -> datetime.datetime:
|
||||
return datetime.datetime.fromtimestamp(self._wall_time(), datetime.UTC).replace(tzinfo=None)
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import typing
|
||||
import inspect
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..core import app
|
||||
from . import operator
|
||||
from ..utils import importutil
|
||||
@@ -66,7 +67,14 @@ class CommandManager:
|
||||
|
||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||
if require_context is not None:
|
||||
result = require_context(context)
|
||||
result = require_context(
|
||||
ExecutionContext(
|
||||
instance_uuid=context.instance_uuid,
|
||||
workspace_uuid=context.workspace_uuid,
|
||||
placement_generation=context.placement_generation,
|
||||
query_uuid=context.query_uuid,
|
||||
)
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from . import entities as core_entities
|
||||
from ..rag.knowledge import kbmgr as rag_mgr
|
||||
from ..rag.service import RAGRuntimeService
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..skill import repository as skill_repository
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
@@ -51,8 +52,10 @@ from ..workspace import collaboration as workspace_collaboration_module
|
||||
from ..workspace import invitation_delivery as invitation_delivery_module
|
||||
from ..cloud import bootstrap as cloud_bootstrap_module
|
||||
from ..cloud import launch as cloud_launch_module
|
||||
from ..cloud import support_admin as cloud_support_admin_module
|
||||
from ..cloud import directory_projection as cloud_directory_projection_module
|
||||
from ..cloud import entitlements as cloud_entitlements_module
|
||||
from ..cloud import model_catalog as cloud_model_catalog_module
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
|
||||
|
||||
@@ -82,6 +85,7 @@ class Application:
|
||||
# TODO move to pipeline
|
||||
tool_mgr: llm_tool_mgr.ToolManager = None
|
||||
box_service: box_service_module.BoxService = None
|
||||
skill_repository: skill_repository.SkillRepository = None
|
||||
|
||||
# ======= Config manager =======
|
||||
|
||||
@@ -136,16 +140,17 @@ class Application:
|
||||
|
||||
space_launch_service: cloud_launch_module.SpaceLaunchService = None
|
||||
|
||||
support_admin_session_service: cloud_support_admin_module.SupportAdminSessionService = None
|
||||
|
||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||
|
||||
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
||||
|
||||
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
||||
cloud_model_catalog_service: cloud_model_catalog_module.CloudModelCatalogSyncService | None = None
|
||||
manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
|
||||
|
||||
entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
|
||||
|
||||
directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
|
||||
|
||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||
|
||||
http_ctrl: http_controller.HTTPController = None
|
||||
@@ -246,6 +251,10 @@ class Application:
|
||||
{},
|
||||
)
|
||||
),
|
||||
'plugin_runtime_connected': bool(
|
||||
self.plugin_connector is not None
|
||||
and getattr(self.plugin_connector, '_runtime_available', lambda: False)()
|
||||
),
|
||||
}
|
||||
mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
|
||||
runtime_stats.update(
|
||||
@@ -294,22 +303,52 @@ class Application:
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
async def _initialize_plugin_runtime(self) -> None:
|
||||
try:
|
||||
await self.plugin_connector.initialize()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
self.plugin_connector.schedule_reconnect()
|
||||
|
||||
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
||||
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||
if task is not None and not task.done():
|
||||
return task
|
||||
# This is application lifecycle work, not a request side effect. It must
|
||||
# not wait on PersistenceManager's after-commit gate at boot.
|
||||
task = asyncio.create_task(
|
||||
self._initialize_plugin_runtime(),
|
||||
name='plugin-runtime-initialization',
|
||||
)
|
||||
self._plugin_runtime_initialization_task = task
|
||||
return task
|
||||
|
||||
async def run(self):
|
||||
self.event_loop_monitor.start()
|
||||
try:
|
||||
if self.directory_projection_service is not None:
|
||||
self.task_mgr.create_task(
|
||||
if (
|
||||
self.directory_projection_service is not None
|
||||
and getattr(self, 'directory_projection_task', None) is None
|
||||
):
|
||||
self.directory_projection_task = self.task_mgr.create_task(
|
||||
self.directory_projection_service.run(),
|
||||
name='cloud-directory-projection',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
if self.cloud_model_catalog_service is not None:
|
||||
self.task_mgr.create_task(
|
||||
self.cloud_model_catalog_service.run(),
|
||||
name='cloud-model-catalog-sync',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
if self.manifest_refresh_service is not None:
|
||||
self.task_mgr.create_task(
|
||||
self.manifest_refresh_service.run(),
|
||||
name='cloud-manifest-refresh',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
await self.plugin_connector.initialize_plugins()
|
||||
|
||||
# 后续可能会允许动态重启其他任务
|
||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||
@@ -335,6 +374,7 @@ class Application:
|
||||
name='http-api-controller',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
self._start_plugin_runtime_initialization()
|
||||
|
||||
# Telemetry instance heartbeat (startup + daily); respects
|
||||
# space.disable_telemetry via TelemetryManager.send().
|
||||
@@ -516,6 +556,11 @@ class Application:
|
||||
|
||||
if self.task_mgr is not None:
|
||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
||||
plugin_runtime_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await plugin_runtime_task
|
||||
with contextlib.suppress(Exception):
|
||||
await self.event_loop_monitor.stop()
|
||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||
@@ -592,9 +637,9 @@ class Application:
|
||||
frontend_path = paths.get_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(
|
||||
'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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import stage, app
|
||||
from .. import stage, app, entities as core_entities
|
||||
from ...utils import version, proxy, constants
|
||||
from ...pipeline import pool, controller, pipelinemgr
|
||||
from ...pipeline import aggregator as message_aggregator
|
||||
@@ -26,6 +26,7 @@ from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...skill import repository as skill_repository
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
from ...skill import manager as skill_mgr
|
||||
@@ -42,9 +43,11 @@ from ...workspace import collaboration as workspace_collaboration_module
|
||||
from ...workspace import invitation_delivery as invitation_delivery_module
|
||||
from ...cloud import bootstrap as cloud_bootstrap
|
||||
from ...cloud import launch as cloud_launch_module
|
||||
from ...cloud import support_admin as cloud_support_admin_module
|
||||
from ...cloud.directory import directory_projection_limits_from_config
|
||||
from ...cloud.directory_projection import DirectoryProjectionService
|
||||
from ...cloud.entitlements import EntitlementResolver
|
||||
from ...cloud.model_catalog import CloudModelCatalogSyncService
|
||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ...api.http.authz import WorkspaceRequiredError
|
||||
|
||||
@@ -125,6 +128,7 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
ap.webhook_service = webhook_service_inst
|
||||
|
||||
ap.skill_repository = skill_repository.SkillRepository(ap)
|
||||
skill_service_inst = skill_service.SkillService(ap)
|
||||
ap.skill_service = skill_service_inst
|
||||
|
||||
@@ -175,11 +179,22 @@ class BuildAppStage(stage.BootingStage):
|
||||
# of repeating tenant validation for every manager.
|
||||
await workspace_service_inst.prime_startup_execution_bindings()
|
||||
|
||||
if not isinstance(deployment, cloud_bootstrap.VerifiedCloudDeployment):
|
||||
raise RuntimeError('Multi-Workspace runtime requires a verified Cloud deployment')
|
||||
cloud_model_catalog_service = CloudModelCatalogSyncService(
|
||||
ap,
|
||||
deployment.model_catalog_provider,
|
||||
constants.instance_id,
|
||||
)
|
||||
await cloud_model_catalog_service.initialize()
|
||||
ap.cloud_model_catalog_service = cloud_model_catalog_service
|
||||
|
||||
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
||||
ap,
|
||||
workspace_service_inst,
|
||||
)
|
||||
ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap)
|
||||
ap.support_admin_session_service = cloud_support_admin_module.SupportAdminSessionService(ap)
|
||||
ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
|
||||
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
@@ -279,14 +294,17 @@ class BuildAppStage(stage.BootingStage):
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
connector.schedule_reconnect()
|
||||
|
||||
if ap.directory_projection_service is not None:
|
||||
# Keep the projection fresh while shared Runtime cold restore runs.
|
||||
# BuildApp initializes the connector before Application.run() starts
|
||||
# its long-lived tasks, so start the single refresh task here.
|
||||
ap.directory_projection_task = ap.task_mgr.create_task(
|
||||
ap.directory_projection_service.run(),
|
||||
name='cloud-directory-projection',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||
try:
|
||||
await plugin_connector_inst.initialize()
|
||||
except Exception as exc:
|
||||
# Keep the API/UI available while an external or managed runtime is
|
||||
# starting, then recover in the background with bounded backoff.
|
||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
plugin_connector_inst.schedule_reconnect()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
workspace_service_inst.release_startup_execution_bindings()
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ _RUNTIME_POLICY_DEFAULTS = {
|
||||
}
|
||||
},
|
||||
'plugin': {
|
||||
'connect_timeout_seconds': 180.0,
|
||||
'worker': {
|
||||
'max_cpus': 1.0,
|
||||
'max_memory_mb': 512,
|
||||
@@ -56,7 +57,7 @@ _RUNTIME_POLICY_DEFAULTS = {
|
||||
'restart_failure_window_seconds': 30.0,
|
||||
'restart_circuit_open_seconds': 60.0,
|
||||
'require_hard_limits': False,
|
||||
}
|
||||
},
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
'monitoring': {
|
||||
|
||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SpaceModelSelection(pydantic.BaseModel):
|
||||
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||
|
||||
uuid: str
|
||||
model_id: str
|
||||
|
||||
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
|
||||
code = 'space_account_binding_required'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return 'This local Account must bind Space from Account settings before Space login'
|
||||
return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
|
||||
|
||||
@@ -48,6 +48,12 @@ class LLMModel(Base):
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
||||
reasoning_config = sqlalchemy.Column(
|
||||
sqlalchemy.JSON,
|
||||
nullable=False,
|
||||
default=lambda: {'level': 'provider_default'},
|
||||
server_default=sqlalchemy.text('\'{"level":"provider_default"}\''),
|
||||
)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class SupportAdminTemporarySession(Base):
|
||||
"""Temporary support-admin Workspace access session."""
|
||||
|
||||
__tablename__ = 'support_admin_temporary_sessions'
|
||||
|
||||
grant_jti_hash = sqlalchemy.Column(sqlalchemy.String(64), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
|
||||
issued_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index(
|
||||
'ix_support_admin_sessions_workspace_expiry',
|
||||
'workspace_uuid',
|
||||
'expires_at',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
'length(grant_jti_hash) = 64',
|
||||
name='ck_support_admin_sessions_grant_jti_hash',
|
||||
),
|
||||
)
|
||||
@@ -40,6 +40,11 @@ class MembershipStatus(enum.StrEnum):
|
||||
REMOVED = 'removed'
|
||||
|
||||
|
||||
class MembershipSource(enum.StrEnum):
|
||||
LOCAL = 'local'
|
||||
CLOUD_PROJECTION = 'cloud_projection'
|
||||
|
||||
|
||||
class InvitationStatus(enum.StrEnum):
|
||||
PENDING = 'pending'
|
||||
ACCEPTED = 'accepted'
|
||||
@@ -151,6 +156,11 @@ class WorkspaceMembership(Base):
|
||||
nullable=True,
|
||||
)
|
||||
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
source = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=MembershipSource.LOCAL.value,
|
||||
)
|
||||
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
@@ -163,6 +173,13 @@ class WorkspaceMembership(Base):
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
||||
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
||||
sqlalchemy.Index(
|
||||
'uq_workspace_memberships_one_active_owner',
|
||||
'workspace_uuid',
|
||||
unique=True,
|
||||
sqlite_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
||||
postgresql_where=sqlalchemy.text("role = 'owner' AND status = 'active'"),
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||
name='ck_workspace_memberships_role',
|
||||
@@ -171,6 +188,10 @@ class WorkspaceMembership(Base):
|
||||
"status IN ('active', 'disabled', 'removed')",
|
||||
name='ck_workspace_memberships_status',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
name='ck_workspace_memberships_source',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,17 @@ down_revision = '0008_mcp_resource_prefs'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
||||
|
||||
|
||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
||||
value = instance_id.strip()
|
||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
||||
try:
|
||||
return str(uuid.UUID(candidate))
|
||||
except ValueError:
|
||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
||||
|
||||
|
||||
def _table_names(conn: sa.Connection) -> set[str]:
|
||||
return set(sa.inspect(conn).get_table_names())
|
||||
@@ -403,7 +414,7 @@ def _bootstrap_default_workspace(conn: sa.Connection) -> None:
|
||||
.values(created_by_account_uuid=owner_account_uuid)
|
||||
)
|
||||
else:
|
||||
workspace_uuid = str(uuid.uuid4())
|
||||
workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid)
|
||||
conn.execute(
|
||||
workspaces.insert().values(
|
||||
uuid=workspace_uuid,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add durable replay protection for signed Space launch assertions
|
||||
|
||||
Revision ID: 0016_space_launch_replay
|
||||
Revises: 0015_cloud_core_collab
|
||||
Create Date: 2026-07-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0016_space_launch_replay'
|
||||
down_revision = '0015_cloud_core_collab'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = 'space_launch_assertion_consumptions'
|
||||
_POLICY = 'langbot_directory_projection'
|
||||
_SETTING = "NULLIF(current_setting('langbot.directory_instance_uuid', true), '')"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _TABLE not in set(sa.inspect(conn).get_table_names()):
|
||||
op.create_table(
|
||||
_TABLE,
|
||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('jti', sa.String(255), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('consumed_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('instance_uuid', 'jti'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_space_launch_assertion_consumptions_expiry',
|
||||
_TABLE,
|
||||
['instance_uuid', 'expires_at'],
|
||||
unique=False,
|
||||
)
|
||||
if conn.dialect.name == 'postgresql':
|
||||
table = conn.dialect.identifier_preparer.quote(_TABLE)
|
||||
policy = conn.dialect.identifier_preparer.quote(_POLICY)
|
||||
expression = f'instance_uuid::text = {_SETTING}'
|
||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
||||
f'USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _TABLE in set(sa.inspect(op.get_bind()).get_table_names()):
|
||||
op.drop_table(_TABLE)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add temporary support-admin sessions
|
||||
|
||||
Revision ID: 0016_support_admin_sessions
|
||||
Revises: 0015_cloud_core_collab
|
||||
Create Date: 2026-07-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '0016_support_admin_sessions'
|
||||
down_revision = '0015_cloud_core_collab'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_TABLE_NAME = 'support_admin_temporary_sessions'
|
||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
_TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
|
||||
|
||||
def _setting(name: str) -> str:
|
||||
return f"NULLIF(current_setting('{name}', true), '')"
|
||||
|
||||
|
||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
||||
return conn.dialect.identifier_preparer.quote(identifier)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
sa.Column('grant_jti_hash', sa.String(64), nullable=False),
|
||||
sa.Column(
|
||||
'workspace_uuid',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('actor_account_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('issued_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
'length(grant_jti_hash) = 64',
|
||||
name='ck_support_admin_sessions_grant_jti_hash',
|
||||
),
|
||||
sa.PrimaryKeyConstraint('grant_jti_hash'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_support_admin_sessions_workspace_expiry',
|
||||
_TABLE_NAME,
|
||||
['workspace_uuid', 'expires_at'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
|
||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
||||
f'USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == 'postgresql':
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
op.drop_index('ix_support_admin_sessions_workspace_expiry', table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""align the OSS Workspace UUID with the persisted instance identity
|
||||
|
||||
Revision ID: 0017_oss_workspace_identity
|
||||
Revises: 0016_support_admin_sessions
|
||||
Create Date: 2026-07-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '0017_oss_workspace_identity'
|
||||
down_revision = '0016_support_admin_sessions'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
|
||||
|
||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
||||
value = instance_id.strip()
|
||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
||||
try:
|
||||
return str(uuid.UUID(candidate))
|
||||
except ValueError:
|
||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
||||
|
||||
|
||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
||||
return conn.dialect.identifier_preparer.quote(identifier)
|
||||
|
||||
|
||||
def _defer_foreign_keys(conn: sa.Connection, inspector: sa.Inspector, table_names: list[str]) -> None:
|
||||
"""Allow the transaction to re-key a connected tenant graph atomically."""
|
||||
|
||||
if conn.dialect.name == 'sqlite':
|
||||
conn.execute(sa.text('PRAGMA defer_foreign_keys = ON'))
|
||||
return
|
||||
if conn.dialect.name != 'postgresql':
|
||||
raise RuntimeError(f'Unsupported Workspace identity migration dialect: {conn.dialect.name}')
|
||||
|
||||
for table_name in table_names:
|
||||
for foreign_key in inspector.get_foreign_keys(table_name):
|
||||
constraint_name = foreign_key.get('name')
|
||||
if not constraint_name:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f'ALTER TABLE {_quote(conn, table_name)} '
|
||||
f'ALTER CONSTRAINT {_quote(conn, constraint_name)} DEFERRABLE INITIALLY DEFERRED'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _suspend_postgres_rls(
|
||||
conn: sa.Connection,
|
||||
table_names: list[str],
|
||||
) -> dict[str, tuple[bool, bool]]:
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return {}
|
||||
|
||||
states: dict[str, tuple[bool, bool]] = {}
|
||||
for table_name in table_names:
|
||||
row = conn.execute(
|
||||
sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'),
|
||||
{'table_name': table_name},
|
||||
).one()
|
||||
enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity)
|
||||
states[table_name] = (enabled, forced)
|
||||
table = _quote(conn, table_name)
|
||||
if forced:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
|
||||
if enabled:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
|
||||
return states
|
||||
|
||||
|
||||
def _restore_postgres_rls(conn: sa.Connection, states: dict[str, tuple[bool, bool]]) -> None:
|
||||
for table_name, (enabled, forced) in states.items():
|
||||
table = _quote(conn, table_name)
|
||||
if enabled:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
if forced:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
table_names = inspector.get_table_names()
|
||||
if 'workspaces' not in table_names:
|
||||
return
|
||||
|
||||
metadata = sa.MetaData()
|
||||
workspaces = sa.Table('workspaces', metadata, autoload_with=conn)
|
||||
local_rows = conn.execute(sa.select(workspaces).where(workspaces.c.source == 'local')).mappings().all()
|
||||
if not local_rows:
|
||||
return
|
||||
if len(local_rows) != 1:
|
||||
raise RuntimeError('Cannot align OSS Workspace identity: expected exactly one local Workspace')
|
||||
|
||||
old_row = dict(local_rows[0])
|
||||
old_uuid = old_row['uuid']
|
||||
canonical_uuid = _workspace_uuid_from_instance_id(old_row['instance_uuid'])
|
||||
if old_uuid == canonical_uuid:
|
||||
return
|
||||
if conn.execute(sa.select(workspaces.c.uuid).where(workspaces.c.uuid == canonical_uuid)).scalar_one_or_none():
|
||||
raise RuntimeError(f'Cannot align OSS Workspace identity: target {canonical_uuid!r} already exists')
|
||||
|
||||
tenant_tables = [
|
||||
table_name
|
||||
for table_name in table_names
|
||||
if table_name == 'workspaces'
|
||||
or 'workspace_uuid' in {column['name'] for column in inspector.get_columns(table_name)}
|
||||
]
|
||||
rls_states = _suspend_postgres_rls(conn, tenant_tables)
|
||||
try:
|
||||
_defer_foreign_keys(conn, inspector, table_names)
|
||||
|
||||
# Release local source/slug uniqueness while the canonical parent exists
|
||||
# alongside the old parent for the duration of this transaction.
|
||||
temporary_slug = f'__workspace_rekey__{old_uuid}'
|
||||
conn.execute(
|
||||
workspaces.update()
|
||||
.where(workspaces.c.uuid == old_uuid)
|
||||
.values(source='cloud_projection', slug=temporary_slug)
|
||||
)
|
||||
new_row = dict(old_row)
|
||||
new_row['uuid'] = canonical_uuid
|
||||
conn.execute(workspaces.insert().values(**new_row))
|
||||
|
||||
for table_name in tenant_tables:
|
||||
if table_name == 'workspaces':
|
||||
continue
|
||||
table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True)
|
||||
conn.execute(table.update().where(table.c.workspace_uuid == old_uuid).values(workspace_uuid=canonical_uuid))
|
||||
|
||||
if 'metadata' in table_names:
|
||||
conn.execute(
|
||||
sa.text('UPDATE metadata SET value = :canonical_uuid WHERE key = :key AND value = :old_uuid'),
|
||||
{
|
||||
'canonical_uuid': canonical_uuid,
|
||||
'key': _OSS_WORKSPACE_METADATA_KEY,
|
||||
'old_uuid': old_uuid,
|
||||
},
|
||||
)
|
||||
conn.execute(workspaces.delete().where(workspaces.c.uuid == old_uuid))
|
||||
if conn.dialect.name == 'postgresql':
|
||||
# Fire deferred FK triggers before ALTER TABLE restores RLS; PostgreSQL
|
||||
# rejects ALTER TABLE while a relation has pending trigger events.
|
||||
conn.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE'))
|
||||
except Exception:
|
||||
# Alembic owns the transaction. Rollback restores the transactional RLS DDL.
|
||||
raise
|
||||
else:
|
||||
_restore_postgres_rls(conn, rls_states)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The previous random UUID is intentionally not recoverable. Keeping the
|
||||
# canonical identity preserves every FK and is safe for older application code.
|
||||
pass
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add llm reasoning config
|
||||
|
||||
Revision ID: 0018_llm_reasoning_config
|
||||
Revises: 0017_oss_workspace_identity
|
||||
Create Date: 2026-07-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0018_llm_reasoning_config'
|
||||
down_revision = '0017_oss_workspace_identity'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_LLM_MODELS = sa.table(
|
||||
'llm_models',
|
||||
sa.column('reasoning_config', sa.JSON()),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'llm_models' not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||
if 'reasoning_config' in columns:
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
'llm_models',
|
||||
sa.Column(
|
||||
'reasoning_config',
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
server_default=sa.text('\'{"level":"provider_default"}\''),
|
||||
),
|
||||
)
|
||||
conn.execute(_LLM_MODELS.update().values(reasoning_config={'level': 'provider_default'}))
|
||||
with op.batch_alter_table('llm_models') as batch_op:
|
||||
batch_op.alter_column('reasoning_config', existing_type=sa.JSON(), nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'llm_models' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||
if 'reasoning_config' in columns:
|
||||
with op.batch_alter_table('llm_models') as batch_op:
|
||||
batch_op.drop_column('reasoning_config')
|
||||
@@ -0,0 +1,21 @@
|
||||
"""merge the published Space launch replay and main migration branches
|
||||
|
||||
Revision ID: 0018_merge_launch_replay
|
||||
Revises: 0016_space_launch_replay, 0017_oss_workspace_identity
|
||||
Create Date: 2026-08-01
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
revision = '0018_merge_launch_replay'
|
||||
down_revision = ('0016_space_launch_replay', '0017_oss_workspace_identity')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,80 @@
|
||||
"""enforce one active owner per Workspace
|
||||
|
||||
Revision ID: 0019_single_workspace_owner
|
||||
Revises: 0018_merge_launch_replay
|
||||
Create Date: 2026-08-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0019_single_workspace_owner'
|
||||
down_revision = '0018_merge_launch_replay'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX_NAME = 'uq_workspace_memberships_one_active_owner'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'workspace_memberships' not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
# Ownership transfer used to promote a second member without demoting the
|
||||
# original owner. Preserve the Workspace creator where possible and demote
|
||||
# every historical extra owner before installing the database invariant.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
WITH ranked_owners AS (
|
||||
SELECT membership.uuid,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY membership.workspace_uuid
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN membership.account_uuid = workspace.created_by_account_uuid THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
COALESCE(membership.joined_at, membership.created_at),
|
||||
membership.uuid
|
||||
) AS owner_rank
|
||||
FROM workspace_memberships AS membership
|
||||
JOIN workspaces AS workspace
|
||||
ON workspace.uuid = membership.workspace_uuid
|
||||
WHERE membership.role = 'owner'
|
||||
AND membership.status = 'active'
|
||||
)
|
||||
UPDATE workspace_memberships
|
||||
SET role = 'admin'
|
||||
WHERE uuid IN (
|
||||
SELECT uuid
|
||||
FROM ranked_owners
|
||||
WHERE owner_rank > 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
# Fresh installations may already have this index because SQLAlchemy
|
||||
# metadata is created before Alembic advances the revision marker.
|
||||
op.execute(
|
||||
sa.text(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS '
|
||||
'uq_workspace_memberships_one_active_owner '
|
||||
'ON workspace_memberships (workspace_uuid) '
|
||||
"WHERE role = 'owner' AND status = 'active'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'workspace_memberships' not in inspector.get_table_names():
|
||||
return
|
||||
index_names = {index['name'] for index in inspector.get_indexes('workspace_memberships')}
|
||||
if _INDEX_NAME in index_names:
|
||||
op.drop_index(_INDEX_NAME, table_name='workspace_memberships')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""enable 3072-dimensional pgvector embeddings
|
||||
|
||||
Revision ID: 001a_pgvector_dimension_3072
|
||||
Revises: 0019_single_workspace_owner
|
||||
Create Date: 2026-08-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '001a_pgvector_dimension_3072'
|
||||
down_revision = '0019_single_workspace_owner'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
_TABLE = 'langbot_vectors'
|
||||
_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
|
||||
_INDEX = 'ix_langbot_vectors_hnsw_cosine_3072'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
|
||||
return
|
||||
op.drop_constraint(_CHECK, _TABLE, type_='check')
|
||||
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536, 3072)')
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE INDEX {_INDEX} ON {_TABLE} USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops) WHERE embedding_dimension = 3072'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
|
||||
return
|
||||
count = conn.scalar(sa.text(f'SELECT COUNT(*) FROM {_TABLE} WHERE embedding_dimension = 3072'))
|
||||
if count:
|
||||
raise RuntimeError('Cannot disable 3072-dimensional pgvector while matching embeddings exist')
|
||||
op.drop_index(_INDEX, table_name=_TABLE)
|
||||
op.drop_constraint(_CHECK, _TABLE, type_='check')
|
||||
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536)')
|
||||
@@ -0,0 +1,49 @@
|
||||
"""add explicit Workspace membership source
|
||||
|
||||
Revision ID: 0020_membership_source
|
||||
Revises: 001a_pgvector_dimension_3072
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0020_membership_source'
|
||||
down_revision = '001a_pgvector_dimension_3072'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_CONSTRAINT_NAME = 'ck_workspace_memberships_source'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'workspace_memberships' not in inspector.get_table_names():
|
||||
return
|
||||
if 'source' in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
||||
return
|
||||
|
||||
# No durable historical field distinguishes Directory-created revision-zero
|
||||
# rows from Core invitations. Protect every existing row; production can
|
||||
# reclassify separately after UUIDs have been verified against Space.
|
||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
||||
batch_op.add_column(sa.Column('source', sa.String(length=32), nullable=False, server_default='local'))
|
||||
batch_op.create_check_constraint(
|
||||
_CONSTRAINT_NAME,
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'workspace_memberships' not in inspector.get_table_names():
|
||||
return
|
||||
if 'source' not in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
|
||||
return
|
||||
with op.batch_alter_table('workspace_memberships') as batch_op:
|
||||
batch_op.drop_constraint(_CONSTRAINT_NAME, type_='check')
|
||||
batch_op.drop_column('source')
|
||||
@@ -0,0 +1,21 @@
|
||||
"""merge reasoning config with the main migration branch
|
||||
|
||||
Revision ID: 0021_merge_reasoning_config
|
||||
Revises: 0020_membership_source, 0018_llm_reasoning_config
|
||||
Create Date: 2026-08-09
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
revision = '0021_merge_reasoning_config'
|
||||
down_revision = ('0020_membership_source', '0018_llm_reasoning_config')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -54,6 +54,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'workspace_memberships',
|
||||
'workspace_invitations',
|
||||
'workspace_execution_states',
|
||||
'support_admin_temporary_sessions',
|
||||
'workspace_metadata',
|
||||
'api_keys',
|
||||
'bots',
|
||||
@@ -97,7 +98,7 @@ _WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
|
||||
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
|
||||
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
|
||||
_RUNTIME_SCHEMA = 'public'
|
||||
_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
|
||||
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
|
||||
@@ -176,7 +177,6 @@ class PersistenceManager:
|
||||
await self._validate_cloud_runtime()
|
||||
return
|
||||
|
||||
self._enable_sqlite_foreign_keys()
|
||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||
async with self._release_migration_lock():
|
||||
await self._initialize_managed_schema()
|
||||
@@ -184,6 +184,7 @@ class PersistenceManager:
|
||||
return
|
||||
|
||||
await self._initialize_managed_schema()
|
||||
await self._enable_sqlite_foreign_keys_after_migration()
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self.write_space_model_providers()
|
||||
@@ -372,6 +373,17 @@ class PersistenceManager:
|
||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||
self._oss_tenant_scope_listener_installed = True
|
||||
|
||||
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
|
||||
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
|
||||
engine = self.get_db_engine()
|
||||
if engine.dialect.name != 'sqlite':
|
||||
return
|
||||
await engine.dispose()
|
||||
self._enable_sqlite_foreign_keys()
|
||||
# Dispose again so every runtime connection is opened through the new
|
||||
# listener instead of reusing a pre-migration pooled connection.
|
||||
await engine.dispose()
|
||||
|
||||
def _enable_sqlite_foreign_keys(self) -> None:
|
||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||
engine = self.get_db_engine()
|
||||
@@ -1355,14 +1367,16 @@ class PersistenceManager:
|
||||
index = by_index.get(index_name)
|
||||
index_definition = normalized(None if index is None else index['definition'])
|
||||
predicate = normalized(None if index is None else index['predicate'])
|
||||
vector_type = 'halfvec' if dimension > 2000 else 'vector'
|
||||
operator_class = f'{vector_type}_cosine_ops'
|
||||
if (
|
||||
index is None
|
||||
or index['access_method'] != 'hnsw'
|
||||
or index['is_valid'] is not True
|
||||
or index['is_ready'] is not True
|
||||
or f'vector({dimension})' not in index_definition
|
||||
or f'(embedding)::vector({dimension})' not in index_definition
|
||||
or 'vector_cosine_ops' not in index_definition
|
||||
or f'{vector_type}({dimension})' not in index_definition
|
||||
or f'(embedding)::{vector_type}({dimension})' not in index_definition
|
||||
or operator_class not in index_definition
|
||||
or predicate.strip('() ') != f'embedding_dimension = {dimension}'
|
||||
):
|
||||
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
@@ -12,6 +13,7 @@ import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import typing
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -81,7 +83,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
|
||||
|
||||
|
||||
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
|
||||
with _open_read_only(path) as connection:
|
||||
with contextlib.closing(_open_read_only(path)) as connection:
|
||||
_verify_connection(connection, expected_revision)
|
||||
|
||||
|
||||
@@ -117,8 +119,23 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _fsync_file(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts.
|
||||
|
||||
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
|
||||
access to the file descriptor).
|
||||
"""
|
||||
|
||||
descriptor: int | None = None
|
||||
for attempt in range(reopen_attempts):
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDWR)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt + 1 >= reopen_attempts:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
assert descriptor is not None
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
@@ -126,13 +143,37 @@ def _fsync_file(path: pathlib.Path) -> None:
|
||||
|
||||
|
||||
def _fsync_directory(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
if os.name == 'nt':
|
||||
# Windows cannot fsync directory handles opened through os.open.
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _remove_stale_temporary_files(
|
||||
directory: pathlib.Path,
|
||||
*,
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
) -> None:
|
||||
"""Remove temporary files left by an interrupted backup or restore."""
|
||||
|
||||
for candidate in directory.iterdir():
|
||||
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
|
||||
continue
|
||||
try:
|
||||
candidate.unlink()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except PermissionError:
|
||||
# Another process may still own this file. Do not turn harmless
|
||||
# cleanup into a migration failure; its unique name cannot collide.
|
||||
continue
|
||||
|
||||
|
||||
def _create_backup(
|
||||
database_path: pathlib.Path,
|
||||
source_revision: str,
|
||||
@@ -141,6 +182,11 @@ def _create_backup(
|
||||
backup_directory = database_path.parent / 'migration-backups'
|
||||
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(backup_directory, 0o700)
|
||||
_remove_stale_temporary_files(
|
||||
backup_directory,
|
||||
prefix=f'.{database_path.stem}-pre-',
|
||||
suffix='.creating',
|
||||
)
|
||||
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
|
||||
stem = (
|
||||
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
|
||||
@@ -157,11 +203,8 @@ def _create_backup(
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(database_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
contextlib.closing(_open_read_only(database_path)) as source,
|
||||
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||
):
|
||||
source.execute('PRAGMA busy_timeout = 30000')
|
||||
source.backup(destination)
|
||||
@@ -209,6 +252,11 @@ async def create_verified_backup(
|
||||
|
||||
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
_verify_file(backup.backup_path, backup.source_revision)
|
||||
_remove_stale_temporary_files(
|
||||
backup.database_path.parent,
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
@@ -218,11 +266,8 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(backup.backup_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
contextlib.closing(_open_read_only(backup.backup_path)) as source,
|
||||
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||
):
|
||||
source.backup(destination)
|
||||
destination.commit()
|
||||
|
||||
@@ -13,7 +13,7 @@ import typing
|
||||
import sqlalchemy
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
import sqlalchemy.orm as sqlalchemy_orm
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from pgvector.sqlalchemy import HALFVEC, Vector
|
||||
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
|
||||
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
|
||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
||||
@@ -43,6 +43,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'workspace_memberships': 'workspace_uuid',
|
||||
'workspace_invitations': 'workspace_uuid',
|
||||
'workspace_execution_states': 'workspace_uuid',
|
||||
'support_admin_temporary_sessions': 'workspace_uuid',
|
||||
'workspace_metadata': 'workspace_uuid',
|
||||
'api_keys': 'workspace_uuid',
|
||||
'bots': 'workspace_uuid',
|
||||
@@ -208,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||
sqlalchemy.sql.dml.UpdateBase,
|
||||
@@ -280,7 +281,7 @@ def _validate_scoped_sql_type(
|
||||
return
|
||||
seen.add(identity)
|
||||
|
||||
if type(sql_type) is Vector:
|
||||
if type(sql_type) in {Vector, HALFVEC}:
|
||||
return
|
||||
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
||||
@@ -461,7 +462,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
||||
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
|
||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
||||
|
||||
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
|
||||
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
|
||||
raise ScopedSessionTransactionError(
|
||||
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
||||
)
|
||||
|
||||
@@ -5,6 +5,11 @@ from .. import entities
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||
|
||||
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||
|
||||
|
||||
@filter_model.filter_class('ban-word-filter')
|
||||
class BanWordFilter(filter_model.ContentFilter):
|
||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
pass
|
||||
|
||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||
words = self.ap.sensitive_meta.data.get('words') or []
|
||||
mask = self.ap.sensitive_meta.data['mask']
|
||||
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||
|
||||
try:
|
||||
found, message = await mask_patterns(
|
||||
self.ap.sensitive_meta.data['words'],
|
||||
found, current = await mask_patterns(
|
||||
words,
|
||||
message,
|
||||
mask=self.ap.sensitive_meta.data['mask'],
|
||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||
)
|
||||
except SafeRegexError as exc:
|
||||
return entities.FilterResult(
|
||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||
replacement=message,
|
||||
replacement=current,
|
||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||
console_notice='',
|
||||
)
|
||||
|
||||
@@ -132,9 +132,7 @@ class Controller:
|
||||
|
||||
break
|
||||
|
||||
if selected_query: # 找到了
|
||||
queries.remove(selected_query)
|
||||
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
|
||||
if not selected_query: # No query is runnable under the current session limits.
|
||||
await self.ap.query_pool.condition.wait()
|
||||
continue
|
||||
|
||||
|
||||
@@ -41,6 +41,29 @@ class PreProcessor(stage.PipelineStage):
|
||||
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
|
||||
return [tool for tool in tools if tool.name in selected_tool_names]
|
||||
|
||||
@staticmethod
|
||||
def _append_to_system_prompt(
|
||||
messages: list[provider_message.Message],
|
||||
addition: str,
|
||||
) -> None:
|
||||
"""Append text to the first system message, creating one if none exists.
|
||||
|
||||
Handles both plain-string and content-element (list) message bodies.
|
||||
"""
|
||||
if messages and messages[0].role == 'system':
|
||||
head = messages[0]
|
||||
if isinstance(head.content, str):
|
||||
head.content = head.content + addition
|
||||
elif isinstance(head.content, list):
|
||||
for ce in head.content:
|
||||
if getattr(ce, 'type', None) == 'text':
|
||||
ce.text = (ce.text or '') + addition
|
||||
break
|
||||
else:
|
||||
head.content.append(provider_message.ContentElement(type='text', text=addition))
|
||||
else:
|
||||
messages.insert(0, provider_message.Message(role='system', content=addition.strip()))
|
||||
|
||||
async def process(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -275,6 +298,23 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
# =========== Current date grounding for the local-agent runner ===========
|
||||
# local-agent system prompts are static strings with no template-variable
|
||||
# support, so without an explicit anchor the LLM resolves relative time
|
||||
# references (e.g. "this quarter", "latest", "currently") against whichever
|
||||
# period is best represented in its training data instead of the real date,
|
||||
# and won't reliably know to double check time-sensitive facts with a tool.
|
||||
if selected_runner == 'local-agent':
|
||||
date_addition = (
|
||||
f'\n\nCurrent date: {datetime.datetime.now().strftime("%Y-%m-%d (%A)")}. '
|
||||
'Resolve relative time references (e.g. "today", "this quarter", "latest", '
|
||||
'"currently") based on this date, not your training cutoff. For anything '
|
||||
'time-sensitive that may have changed since training — stock prices, '
|
||||
'financial results, news, current events, exchange rates, or similar — '
|
||||
'verify with a search tool if one is available rather than answering from memory.'
|
||||
)
|
||||
self._append_to_system_prompt(query.prompt.messages, date_addition)
|
||||
|
||||
# =========== Skill awareness for the local-agent runner ===========
|
||||
# The actual activation goes through the ``activate`` Tool Call so the
|
||||
# LLM doesn't see full SKILL.md instructions until it commits to a
|
||||
@@ -288,6 +328,8 @@ class PreProcessor(stage.PipelineStage):
|
||||
# relied on this injection; without it the LLM never discovers
|
||||
# the skills are there and just calls native tools instead.
|
||||
if selected_runner == 'local-agent' and self.ap.skill_mgr:
|
||||
available_tool_names = {tool.name for tool in query.use_funcs}
|
||||
query.variables['_skill_execution_available'] = 'exec' in available_tool_names
|
||||
skill_execution_context = get_query_execution_context(query)
|
||||
await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(
|
||||
@@ -309,35 +351,15 @@ class PreProcessor(stage.PipelineStage):
|
||||
skill_execution_context,
|
||||
bound_skills=bound_skills,
|
||||
)
|
||||
if skill_addition:
|
||||
# Append to the first system message; create one if the
|
||||
# prompt has none. Handles both plain-string and
|
||||
# content-element (list) message bodies.
|
||||
if query.prompt.messages and query.prompt.messages[0].role == 'system':
|
||||
head = query.prompt.messages[0]
|
||||
if isinstance(head.content, str):
|
||||
head.content = head.content + skill_addition
|
||||
elif isinstance(head.content, list):
|
||||
appended = False
|
||||
for ce in head.content:
|
||||
if getattr(ce, 'type', None) == 'text':
|
||||
ce.text = (ce.text or '') + skill_addition
|
||||
appended = True
|
||||
break
|
||||
if not appended:
|
||||
head.content.append(provider_message.ContentElement(type='text', text=skill_addition))
|
||||
else:
|
||||
query.prompt.messages.insert(
|
||||
0,
|
||||
provider_message.Message(role='system', content=skill_addition.strip()),
|
||||
)
|
||||
if skill_addition and 'activate' in available_tool_names:
|
||||
self._append_to_system_prompt(query.prompt.messages, skill_addition)
|
||||
self.ap.logger.debug(
|
||||
f'Skill index injected into system prompt: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'bound_skills={bound_skills or "all"} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
|
||||
)
|
||||
else:
|
||||
elif 'activate' in available_tool_names:
|
||||
self.ap.logger.debug(
|
||||
f'No skills available for prompt injection: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
|
||||
@@ -15,6 +15,7 @@ from ....provider import runner as runner_module
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ....utils import importutil, constants, runner as runner_utils
|
||||
from ....telemetry import features as telemetry_features
|
||||
from ....telemetry.identity import workspace_identity
|
||||
from ....provider import runners
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -265,7 +266,8 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
'duration_ms': duration_ms,
|
||||
'model_name': model_name,
|
||||
'version': constants.semantic_version,
|
||||
'instance_id': constants.instance_id,
|
||||
**workspace_identity(get_query_execution_context(query)),
|
||||
'runtime_instance_id': constants.instance_id,
|
||||
'edition': constants.edition,
|
||||
'pipeline_plugins': pipeline_plugins,
|
||||
'features': features,
|
||||
|
||||
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=query,
|
||||
)
|
||||
elif (
|
||||
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||
):
|
||||
# Final streaming chunk with no text content but
|
||||
# possibly carrying sandbox outbox attachments.
|
||||
reply_chain = platform_message.MessageChain([])
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
yield entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=query,
|
||||
)
|
||||
|
||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||
function_names = [tc.function.name for tc in result.tool_calls]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user