mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-30 16:16:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8737b818b6 |
@@ -1,166 +0,0 @@
|
||||
# LangBot 多租户数据库迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
|
||||
|
||||
## 迁移序列
|
||||
|
||||
```
|
||||
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
|
||||
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
|
||||
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
|
||||
0012_plugin_installation_identity → 插件实例租户绑定
|
||||
0013_tenant_pgvector → RAG 向量存储隔离
|
||||
0014_cloud_directory_projection → Cloud 控制平面同步
|
||||
0015_cloud_core_collaboration → 协作和权限功能
|
||||
```
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 备份(必须)
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
|
||||
|
||||
# PostgreSQL
|
||||
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
|
||||
```
|
||||
|
||||
### 2. 执行迁移
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 执行迁移
|
||||
python -m langbot.pkg.persistence.migration upgrade head
|
||||
|
||||
# 验证
|
||||
python -m langbot.pkg.persistence.migration current
|
||||
# 预期: 0015_cloud_core_collaboration
|
||||
|
||||
# 启动服务
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 3. OSS 单租户自动迁移
|
||||
|
||||
迁移会自动:
|
||||
- 创建默认 Workspace(名称:"Default Workspace")
|
||||
- 第一个用户成为 Owner
|
||||
- 所有现有资源绑定到该 Workspace
|
||||
|
||||
### 4. 验证检查
|
||||
|
||||
```bash
|
||||
# 检查 Workspace
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
|
||||
print(f"Workspace: {ws[1]} ({ws[0]})")
|
||||
|
||||
# 检查资源绑定
|
||||
bot_count = conn.execute(sa.text(
|
||||
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
|
||||
)).scalar()
|
||||
print(f"Bots: {bot_count}")
|
||||
EOF
|
||||
```
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 完全回滚(丢失多租户数据)
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
sudo -S -p '' systemctl stop langbot
|
||||
|
||||
# 2. 恢复备份
|
||||
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
|
||||
|
||||
# 3. 回退代码
|
||||
git checkout v4.10.x
|
||||
pip install -e .
|
||||
|
||||
# 4. 启动
|
||||
sudo -S -p '' systemctl start langbot
|
||||
```
|
||||
|
||||
### 降级迁移(保留数据但移除多租户)
|
||||
|
||||
```bash
|
||||
# 警告:会移除 Workspace 表但保留资源
|
||||
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移后无法登录
|
||||
|
||||
```bash
|
||||
# 检查用户 UUID
|
||||
python << EOF
|
||||
from langbot.pkg.persistence import manager
|
||||
import sqlalchemy as sa
|
||||
|
||||
with manager.engine.connect() as conn:
|
||||
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
|
||||
for u in users:
|
||||
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
|
||||
EOF
|
||||
```
|
||||
|
||||
### Q: 资源看不见了
|
||||
|
||||
检查 Workspace 上下文:
|
||||
```bash
|
||||
# 前端请求需要带 X-Workspace-ID header
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-Workspace-ID: $WORKSPACE_UUID" \
|
||||
http://localhost:5200/api/v1/platform/bots
|
||||
```
|
||||
|
||||
### Q: 迁移速度慢
|
||||
|
||||
```bash
|
||||
# SQLite 优化
|
||||
sqlite3 ~/.langbot/data/langbot.db << EOF
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
VACUUM;
|
||||
EOF
|
||||
```
|
||||
|
||||
## 性能调优
|
||||
|
||||
### PostgreSQL 索引
|
||||
|
||||
```sql
|
||||
-- 迁移后创建
|
||||
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
|
||||
ON model_providers(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_bots_workspace
|
||||
ON bots(workspace_uuid);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
|
||||
ON pipelines(workspace_uuid);
|
||||
```
|
||||
|
||||
## 预估时间
|
||||
|
||||
- SQLite < 100MB: 2-5 分钟
|
||||
- SQLite 100MB-1GB: 5-15 分钟
|
||||
- PostgreSQL: < 5 分钟(取决于数据量)
|
||||
|
||||
## 支持
|
||||
|
||||
问题反馈:https://github.com/langbot-app/LangBot/issues
|
||||
|
||||
**版本**: 1.0
|
||||
**最后更新**: 2026-07-30
|
||||
@@ -23,13 +23,8 @@ def _storage_owner(context: RequestContext) -> str:
|
||||
@group.group_class('files', '/api/v1/files')
|
||||
class FilesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route(
|
||||
'/image/<path:image_key>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
|
||||
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='upload_image',
|
||||
|
||||
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
) -> RequestContext:
|
||||
) -> None:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
@@ -168,7 +168,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
entitlement_revision=request_context.entitlement_revision,
|
||||
)
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
return current_context
|
||||
|
||||
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
||||
pipeline = await run_in_workspace_uow(
|
||||
|
||||
@@ -2,7 +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
|
||||
@@ -24,11 +23,6 @@ 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:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
@@ -56,14 +56,6 @@ class KnowledgeService:
|
||||
require_workspace_uuid(context)
|
||||
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
||||
# Legacy internal KB creation is removed.
|
||||
limitation = (
|
||||
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
|
||||
)
|
||||
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
|
||||
if max_knowledge_bases >= 0:
|
||||
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
|
||||
if len(knowledge_bases) >= max_knowledge_bases:
|
||||
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
|
||||
|
||||
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
|
||||
if not knowledge_engine_plugin_id:
|
||||
|
||||
@@ -331,15 +331,8 @@ class ModelProviderService:
|
||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
context, provider_uuid
|
||||
)
|
||||
rerank_service = getattr(self.ap, 'rerank_models_service', None)
|
||||
rerank_models = (
|
||||
await rerank_service.get_rerank_models_by_provider(context, provider_uuid)
|
||||
if rerank_service is not None
|
||||
else []
|
||||
)
|
||||
existing_llm_names = {model['name'] for model in llm_models}
|
||||
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||
existing_rerank_names = {model['name'] for model in rerank_models}
|
||||
|
||||
filtered_models = []
|
||||
for model in scanned_models:
|
||||
@@ -366,8 +359,6 @@ class ModelProviderService:
|
||||
'already_added': (
|
||||
model_name in existing_embedding_names
|
||||
if scanned_type == 'embedding'
|
||||
else model_name in existing_rerank_names
|
||||
if scanned_type == 'rerank'
|
||||
else model_name in existing_llm_names
|
||||
),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ....cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from ....core import app
|
||||
from ....skill.utils import parse_frontmatter
|
||||
from ....utils import httpclient
|
||||
@@ -119,7 +120,13 @@ class SkillService:
|
||||
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)]
|
||||
try:
|
||||
skills = await box_service.list_skills(execution_context)
|
||||
except EntitlementFeatureUnavailableError as error:
|
||||
if error.feature == 'managed_sandbox':
|
||||
return []
|
||||
raise
|
||||
return [self._serialize_skill(skill) for skill in skills]
|
||||
|
||||
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
|
||||
@@ -18,19 +18,11 @@ class EntitlementUnavailableError(RuntimeError):
|
||||
|
||||
|
||||
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
|
||||
"""Raised only when an active entitlement does not grant one feature."""
|
||||
"""Raised when an active entitlement explicitly omits a capability."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
feature: str,
|
||||
*,
|
||||
entitlement_revision: int | None = None,
|
||||
) -> None:
|
||||
def __init__(self, message: str, *, feature: str, entitlement_revision: int | None = None) -> None:
|
||||
super().__init__(message, entitlement_revision=entitlement_revision)
|
||||
self.feature = feature
|
||||
super().__init__(
|
||||
f'Workspace entitlement does not grant {feature}',
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
|
||||
|
||||
class EntitlementSnapshot(pydantic.BaseModel):
|
||||
@@ -99,7 +91,8 @@ class EntitlementSnapshot(pydantic.BaseModel):
|
||||
def require_feature(self, feature: str) -> None:
|
||||
if self.features.get(feature) is not True:
|
||||
raise EntitlementFeatureUnavailableError(
|
||||
feature,
|
||||
f'Workspace entitlement does not grant {feature}',
|
||||
feature=feature,
|
||||
entitlement_revision=self.entitlement_revision,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .. import handler
|
||||
from ... import entities
|
||||
from ... import plugin_diagnostics
|
||||
from ....entity.persistence.bot import BotAdmin
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -26,14 +24,7 @@ class CommandHandler(handler.MessageHandler):
|
||||
|
||||
privilege = 1
|
||||
|
||||
admins = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(BotAdmin).where(
|
||||
BotAdmin.bot_uuid == (query.bot_uuid or ''),
|
||||
BotAdmin.launcher_type == query.launcher_type.value,
|
||||
BotAdmin.launcher_id == str(query.launcher_id),
|
||||
)
|
||||
)
|
||||
if admins.first() is not None:
|
||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||
privilege = 2
|
||||
|
||||
spt = command_text.split(' ')
|
||||
|
||||
@@ -241,11 +241,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
async def process_message_data(msg_data, reply_list):
|
||||
if msg_data['type'] == 'image':
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
|
||||
reply_list.append(
|
||||
platform_message.Image(
|
||||
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
)
|
||||
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
|
||||
elif msg_data['type'] == 'text':
|
||||
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
|
||||
@@ -290,9 +286,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
|
||||
else:
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
|
||||
image_msg = platform_message.Image(
|
||||
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
yiri_msg_list.append(image_msg)
|
||||
elif msg.type == 'forward':
|
||||
# 暂时不太合理
|
||||
|
||||
@@ -764,9 +764,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
)
|
||||
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
|
||||
image_format = response.headers['Content-Type']
|
||||
element_list.append(
|
||||
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
|
||||
)
|
||||
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
|
||||
return platform_message.MessageChain(element_list)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
if pic_url is not None:
|
||||
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
if pic_url is not None:
|
||||
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -181,10 +181,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
|
||||
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
||||
message_components.append(
|
||||
platform_message.Image(
|
||||
url=file.file_path,
|
||||
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
|
||||
)
|
||||
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
|
||||
)
|
||||
|
||||
if message.voice:
|
||||
|
||||
@@ -133,9 +133,7 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
|
||||
yiri_msg_list.append(
|
||||
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
)
|
||||
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
@@ -529,7 +529,6 @@ class ModelManager:
|
||||
model['uuid']: model
|
||||
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
|
||||
}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
@@ -598,36 +597,6 @@ class ModelManager:
|
||||
)
|
||||
updated += 1
|
||||
|
||||
elif space_model.category == 'rerank':
|
||||
existing = existing_rerank_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
await self.ap.rerank_models_service.create_rerank_model(
|
||||
context,
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
created += 1
|
||||
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||
desired = {
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
}
|
||||
if (
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.rerank_models_service.update_rerank_model(
|
||||
context, space_model.uuid, dict(desired)
|
||||
)
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
|
||||
|
||||
|
||||
@@ -944,21 +944,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
request_args = dict(extra_args)
|
||||
rerank_url = request_args.pop('rerank_url', None)
|
||||
rerank_path = request_args.pop('rerank_path', 'rerank')
|
||||
|
||||
payload: dict[str, typing.Any] = {
|
||||
'model': model_name,
|
||||
'query': query,
|
||||
'documents': documents,
|
||||
'top_n': top_n,
|
||||
}
|
||||
if request_args:
|
||||
payload.update(request_args)
|
||||
if extra_args:
|
||||
payload.update(extra_args)
|
||||
|
||||
if not rerank_url:
|
||||
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
|
||||
rerank_url = f'{base_url}/rerank'
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
|
||||
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
|
||||
return await self._run(operation, session=session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _invitation_lock(self, lock_key: str):
|
||||
"""Serialize one token within workspace scope while retaining only active lock entries."""
|
||||
async def _invitation_lock(self, token_digest: str):
|
||||
"""Serialize one token while retaining only active lock entries."""
|
||||
|
||||
async with self._invitation_locks_guard:
|
||||
entry = self._invitation_locks.get(lock_key)
|
||||
entry = self._invitation_locks.get(token_digest)
|
||||
if entry is None:
|
||||
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
||||
self._invitation_locks[lock_key] = entry
|
||||
self._invitation_locks[token_digest] = entry
|
||||
entry.users += 1
|
||||
|
||||
await entry.lock.acquire()
|
||||
@@ -517,7 +517,7 @@ class WorkspaceCollaborationService:
|
||||
async with self._invitation_locks_guard:
|
||||
entry.users -= 1
|
||||
if entry.users == 0:
|
||||
self._invitation_locks.pop(lock_key, None)
|
||||
self._invitation_locks.pop(token_digest, None)
|
||||
|
||||
async def revoke_invitation(
|
||||
self,
|
||||
|
||||
@@ -105,7 +105,6 @@ system:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
max_knowledge_bases: -1
|
||||
# When set to a non-empty string, every pipeline is forced to use this
|
||||
# Box sandbox-scope template regardless of its own configuration, and
|
||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Skills API behavior when a workspace plan has no managed sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
|
||||
from langbot.pkg.cloud.entitlements import (
|
||||
EntitlementFeatureUnavailableError,
|
||||
EntitlementUnavailableError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def skills_api():
|
||||
account = SimpleNamespace(uuid='owner-account', user='owner@example.com')
|
||||
access = SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
|
||||
membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1),
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||
application.skill_service.list_skills = AsyncMock(
|
||||
side_effect=EntitlementFeatureUnavailableError(
|
||||
'managed_sandbox',
|
||||
entitlement_revision=1,
|
||||
)
|
||||
)
|
||||
|
||||
quart_app = quart.Quart(__name__)
|
||||
router = SkillsRouterGroup(application, quart_app)
|
||||
await router.initialize()
|
||||
return application, quart_app.test_client()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
|
||||
application, client = skills_api
|
||||
response = await client.get(
|
||||
'/api/v1/skills',
|
||||
headers={
|
||||
'Authorization': 'Bearer owner-token',
|
||||
'X-Workspace-Id': WORKSPACE_UUID,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data'] == {'skills': []}
|
||||
application.skill_service.list_skills.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
|
||||
application, client = skills_api
|
||||
application.skill_service.list_skills.side_effect = EntitlementUnavailableError(
|
||||
'Workspace entitlement revision rolled back'
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/skills',
|
||||
headers={
|
||||
'Authorization': 'Bearer owner-token',
|
||||
'X-Workspace-Id': WORKSPACE_UUID,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
@@ -34,7 +34,6 @@ class _Rows:
|
||||
def _app():
|
||||
return SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
rag_mgr=SimpleNamespace(
|
||||
get_all_knowledge_base_details=AsyncMock(return_value=[]),
|
||||
get_knowledge_base_details=AsyncMock(return_value=None),
|
||||
@@ -120,21 +119,6 @@ async def test_create_validates_schema_and_binds_context():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_enforces_workspace_knowledge_base_limit():
|
||||
app = _app()
|
||||
app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
|
||||
app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
|
||||
service = KnowledgeService(app)
|
||||
|
||||
with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
|
||||
await service.create_knowledge_base(
|
||||
CONTEXT,
|
||||
{'knowledge_engine_plugin_id': 'author/engine'},
|
||||
)
|
||||
app.rag_mgr.create_knowledge_base.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_guessed_uuid_and_scopes_reload():
|
||||
app = _app()
|
||||
|
||||
@@ -833,44 +833,6 @@ class TestModelProviderServiceScanProviderModels:
|
||||
assert len(result['models']) == 1
|
||||
assert result['models'][0]['type'] == 'llm'
|
||||
|
||||
async def test_scan_provider_marks_existing_rerank_model(self):
|
||||
"""Rerank scan results use the rerank service when computing already_added."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.llm_model_service = SimpleNamespace()
|
||||
ap.embedding_models_service = SimpleNamespace()
|
||||
ap.rerank_models_service = SimpleNamespace()
|
||||
|
||||
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'uuid': 'rerank-scan-uuid',
|
||||
'name': 'New API',
|
||||
'requester': 'new-api-chat-completions',
|
||||
'base_url': 'https://new-api.example.com/v1',
|
||||
'api_keys': ['key'],
|
||||
}
|
||||
)
|
||||
|
||||
runtime_provider = Mock()
|
||||
runtime_provider.token_mgr.get_token.return_value = 'token'
|
||||
runtime_provider.requester.scan_models = AsyncMock(
|
||||
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
|
||||
)
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models(
|
||||
WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
|
||||
)
|
||||
|
||||
assert result['models'][0]['type'] == 'rerank'
|
||||
assert result['models'][0]['already_added'] is True
|
||||
|
||||
async def test_scan_provider_not_implemented_raises_error(self):
|
||||
"""Raises ValueError when scan not implemented."""
|
||||
# Setup
|
||||
|
||||
@@ -756,7 +756,7 @@ class TestSpaceServiceGetModels:
|
||||
'uuid': 'uuid-2',
|
||||
'model_id': 'model-2',
|
||||
'provider': 'provider-2',
|
||||
'category': 'rerank',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
@@ -778,7 +778,6 @@ class TestSpaceServiceGetModels:
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert result[1].category == 'rerank'
|
||||
|
||||
async def test_get_models_api_error(self):
|
||||
"""Raises ValueError on API error."""
|
||||
|
||||
@@ -158,21 +158,17 @@ class TestCommandHandlerReal:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""A per-bot admin from the database is marked as admin in command events."""
|
||||
"""Admin users get privilege level 2."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = Mock()
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 12345
|
||||
|
||||
@@ -180,28 +176,23 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""A launcher absent from the per-bot admin table is not an admin."""
|
||||
"""Non-admin users get privilege level 1."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = None
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 67890
|
||||
|
||||
@@ -209,7 +200,6 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is False
|
||||
|
||||
@@ -1147,46 +1147,6 @@ class TestInvokeRerank:
|
||||
assert results[0]['relevance_score'] == 1.0
|
||||
assert results[1]['relevance_score'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('model_extra_args', 'expected_url'),
|
||||
[
|
||||
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
|
||||
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
|
||||
],
|
||||
)
|
||||
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
|
||||
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://gateway.example.com/v1/',
|
||||
'custom_llm_provider': 'openai',
|
||||
},
|
||||
)
|
||||
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
|
||||
model.model_entity.extra_args = model_extra_args
|
||||
|
||||
mock_resp = Mock()
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
await requester.invoke_rerank(model=model, query='query', documents=['document'])
|
||||
|
||||
assert mock_client.post.call_args.args[0] == expected_url
|
||||
payload = mock_client.post.call_args.kwargs['json']
|
||||
assert payload == {
|
||||
'model': 'Qwen3-Reranker-8B',
|
||||
'query': 'query',
|
||||
'documents': ['document'],
|
||||
'top_n': 1,
|
||||
}
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
"""Test _convert_messages method"""
|
||||
|
||||
@@ -86,49 +86,6 @@ async def test_model_manager_skips_legacy_space_sync_in_cloud_runtime(mock_app_f
|
||||
app.workspace_service.get_local_execution_binding.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
|
||||
"""Space rerank entries are discovered and persisted under the shared provider."""
|
||||
app = mock_app_for_modelmgr
|
||||
provider = persistence_model.ModelProvider(
|
||||
uuid='space-provider',
|
||||
name='LangBot Space',
|
||||
requester='space-chat-completions',
|
||||
base_url='https://api.langbot.cloud/v1',
|
||||
api_keys=['space-key'],
|
||||
)
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
|
||||
app.space_service.get_models = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
uuid='rerank-model-uuid',
|
||||
model_id='Qwen3-Reranker-8B',
|
||||
category='rerank',
|
||||
featured_order=10,
|
||||
)
|
||||
]
|
||||
)
|
||||
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
|
||||
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
|
||||
app.rerank_models_service = AsyncMock()
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
|
||||
|
||||
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
{
|
||||
'uuid': 'rerank-model-uuid',
|
||||
'name': 'Qwen3-Reranker-8B',
|
||||
'provider_uuid': 'space-provider',
|
||||
'extra_args': {},
|
||||
'prefered_ranking': 10,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Model Loading Tests
|
||||
# ============================================================================
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.skill import SkillService
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
|
||||
|
||||
|
||||
_CONTEXT = ExecutionContext(
|
||||
@@ -113,6 +114,42 @@ class TestRequireBoxForWrite:
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
assert await service.list_skills(_CONTEXT) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_empty_when_managed_sandbox_is_not_granted(self):
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
list_skills=AsyncMock(
|
||||
side_effect=EntitlementFeatureUnavailableError(
|
||||
'Workspace entitlement does not grant managed_sandbox',
|
||||
feature='managed_sandbox',
|
||||
)
|
||||
),
|
||||
)
|
||||
service = SkillService(
|
||||
SimpleNamespace(
|
||||
workspace_service=_workspace_service(),
|
||||
box_service=box_service,
|
||||
)
|
||||
)
|
||||
|
||||
assert await service.list_skills(_CONTEXT) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_preserves_other_entitlement_failures(self):
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
list_skills=AsyncMock(side_effect=EntitlementUnavailableError('control plane unavailable')),
|
||||
)
|
||||
service = SkillService(
|
||||
SimpleNamespace(
|
||||
workspace_service=_workspace_service(),
|
||||
box_service=box_service,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(EntitlementUnavailableError, match='control plane unavailable'):
|
||||
await service.list_skills(_CONTEXT)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_skill_file_refused_when_box_unavailable(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
|
||||
Reference in New Issue
Block a user