mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-31 08:36:07 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4078c8d16c | |||
| 6f4200ee36 | |||
| ae9769c81e | |||
| 32f2a1bf88 | |||
| 0e30b32889 | |||
| 6b8838a308 | |||
| 5ba5e60002 | |||
| 7b03e3395f | |||
| d3c443a2c8 | |||
| 463b120923 | |||
| 3ca724d18e | |||
| dd8d1007a1 |
@@ -0,0 +1,166 @@
|
||||
# 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
|
||||
@@ -164,7 +164,7 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
try:
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
request_context = await self._resolve_account_context(account, auth_type)
|
||||
request_context = await self._resolve_account_context(account, auth_type, token=token)
|
||||
if permission is not None:
|
||||
if request_context is None:
|
||||
raise AuthorizationError('Workspace authorization is unavailable')
|
||||
@@ -272,6 +272,8 @@ class RouterGroup(abc.ABC):
|
||||
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)
|
||||
@@ -280,6 +282,20 @@ class RouterGroup(abc.ABC):
|
||||
return None
|
||||
|
||||
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
|
||||
scope_resolver = getattr(self.ap.user_service, 'get_admin_owner_scope', None)
|
||||
admin_owner_scope = typing.cast(
|
||||
dict[str, str] | None,
|
||||
scope_resolver(token) if token and callable(scope_resolver) else None,
|
||||
)
|
||||
if admin_owner_scope is not None:
|
||||
scoped_workspace_uuid = admin_owner_scope['workspace_uuid']
|
||||
if requested_workspace_uuid and requested_workspace_uuid != scoped_workspace_uuid:
|
||||
self.ap.logger.warning(
|
||||
'cloud_admin_owner_scope_rejected actor_account_uuid=%s target_workspace_uuid=%s requested_workspace_uuid=%s',
|
||||
admin_owner_scope['actor_account_uuid'], scoped_workspace_uuid, requested_workspace_uuid,
|
||||
)
|
||||
raise MembershipPermissionError('Admin owner session is scoped to another Workspace')
|
||||
requested_workspace_uuid = scoped_workspace_uuid
|
||||
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
access.execution.instance_uuid,
|
||||
|
||||
@@ -23,8 +23,13 @@ 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.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
@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:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='upload_image',
|
||||
|
||||
@@ -98,6 +98,17 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
raise ValueError('Authentication is required')
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
scope_resolver = getattr(self.ap.user_service, 'get_admin_owner_scope', None)
|
||||
admin_owner_scope = typing.cast(
|
||||
dict[str, str] | None,
|
||||
scope_resolver(token) if callable(scope_resolver) else None,
|
||||
)
|
||||
if admin_owner_scope is not None and workspace_uuid != admin_owner_scope['workspace_uuid']:
|
||||
self.ap.logger.warning(
|
||||
'cloud_admin_owner_scope_rejected actor_account_uuid=%s target_workspace_uuid=%s requested_workspace_uuid=%s',
|
||||
admin_owner_scope['actor_account_uuid'], admin_owner_scope['workspace_uuid'], workspace_uuid,
|
||||
)
|
||||
raise ValueError('Admin owner session is scoped to another Workspace')
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
if not isinstance(account_uuid, str) or collaboration_service is None:
|
||||
@@ -128,7 +139,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
) -> None:
|
||||
) -> RequestContext:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
@@ -168,6 +179,7 @@ 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,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
@@ -23,6 +24,11 @@ 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})
|
||||
|
||||
@@ -404,7 +404,19 @@ class UserRouterGroup(group.RouterGroup):
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
admin_owner_scope = None
|
||||
if launch.get('launch_mode') == 'admin_owner':
|
||||
if access.membership.role != 'owner':
|
||||
raise SpaceLaunchError('Admin launch principal is not an active Workspace owner')
|
||||
admin_owner_scope = {
|
||||
'actor_account_uuid': launch['actor_account_uuid'],
|
||||
'workspace_uuid': launch['workspace_uuid'],
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
token = await self.ap.user_service.generate_jwt_token(
|
||||
account,
|
||||
admin_owner_scope=admin_owner_scope,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
|
||||
@@ -56,6 +56,14 @@ 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,8 +331,15 @@ 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:
|
||||
@@ -359,6 +366,8 @@ 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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -400,9 +400,21 @@ class UserService:
|
||||
|
||||
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,
|
||||
*,
|
||||
admin_owner_scope: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
|
||||
if admin_owner_scope is not None:
|
||||
expected = {'actor_account_uuid', 'workspace_uuid', 'effective_role'}
|
||||
if set(admin_owner_scope) != expected or admin_owner_scope.get('effective_role') != 'owner':
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
if not admin_owner_scope.get('actor_account_uuid') or not admin_owner_scope.get('workspace_uuid'):
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
jwt_expire = min(int(jwt_expire), 300)
|
||||
|
||||
account_obj: user.User | None = account if not isinstance(account, str) and hasattr(account, 'user') else None
|
||||
user_email = account_obj.user if account_obj is not None else account
|
||||
@@ -413,7 +425,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],
|
||||
@@ -427,9 +439,39 @@ class UserService:
|
||||
'account_revision': account_obj.projection_revision,
|
||||
}
|
||||
)
|
||||
if admin_owner_scope is not None:
|
||||
payload['admin_owner_scope'] = dict(admin_owner_scope)
|
||||
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256')
|
||||
|
||||
def get_admin_owner_scope(self, token: str) -> dict[str, str] | None:
|
||||
# Authentication already ran. Decode only to detect whether this optional
|
||||
# claim exists, preserving bounded legacy tokens that omit iss/aud.
|
||||
unverified = jwt.decode(token, options={'verify_signature': False})
|
||||
if unverified.get('admin_owner_scope') is None:
|
||||
return None
|
||||
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
issuer, audience = self._jwt_identity()
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=['HS256'],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
options={'require': ['exp', 'iss', 'aud']},
|
||||
)
|
||||
scope = payload.get('admin_owner_scope')
|
||||
if not isinstance(scope, dict) or set(scope) != {'actor_account_uuid', 'workspace_uuid', 'effective_role'}:
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
if scope.get('effective_role') != 'owner':
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
actor = scope.get('actor_account_uuid')
|
||||
workspace = scope.get('workspace_uuid')
|
||||
if not isinstance(actor, str) or not actor or not isinstance(workspace, str) or not workspace:
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
return {'actor_account_uuid': actor, 'workspace_uuid': workspace, 'effective_role': 'owner'}
|
||||
|
||||
async def verify_jwt_token(self, token: str) -> str:
|
||||
account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
|
||||
if isinstance(account, str):
|
||||
|
||||
@@ -17,6 +17,22 @@ class EntitlementUnavailableError(RuntimeError):
|
||||
self.entitlement_revision = entitlement_revision
|
||||
|
||||
|
||||
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
|
||||
"""Raised only when an active entitlement does not grant one feature."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
feature: str,
|
||||
*,
|
||||
entitlement_revision: int | None = None,
|
||||
) -> None:
|
||||
self.feature = feature
|
||||
super().__init__(
|
||||
f'Workspace entitlement does not grant {feature}',
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
|
||||
|
||||
class EntitlementSnapshot(pydantic.BaseModel):
|
||||
"""Capability projection consumed by open-source Core.
|
||||
|
||||
@@ -82,7 +98,10 @@ class EntitlementSnapshot(pydantic.BaseModel):
|
||||
|
||||
def require_feature(self, feature: str) -> None:
|
||||
if self.features.get(feature) is not True:
|
||||
raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}')
|
||||
raise EntitlementFeatureUnavailableError(
|
||||
feature,
|
||||
entitlement_revision=self.entitlement_revision,
|
||||
)
|
||||
|
||||
def limit(self, name: str) -> int:
|
||||
value = self.limits.get(name)
|
||||
|
||||
@@ -127,11 +127,33 @@ class SpaceLaunchService:
|
||||
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 {
|
||||
launch_mode = payload.get('launch_mode')
|
||||
result = {
|
||||
'account_uuid': account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
}
|
||||
if launch_mode is not None:
|
||||
if launch_mode != 'admin_owner':
|
||||
raise SpaceLaunchError('Launch assertion mode is unsupported')
|
||||
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')
|
||||
result.update({
|
||||
'launch_mode': 'admin_owner',
|
||||
'actor_account_uuid': actor_account_uuid,
|
||||
'effective_role': 'owner',
|
||||
})
|
||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||
if launch_mode == 'admin_owner':
|
||||
self.ap.logger.info(
|
||||
'cloud_admin_owner_launch_consumed actor_account_uuid=%s workspace_uuid=%s owner_account_uuid=%s',
|
||||
result['actor_account_uuid'], workspace_uuid, account_uuid,
|
||||
)
|
||||
return result
|
||||
|
||||
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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
|
||||
@@ -24,7 +26,14 @@ class CommandHandler(handler.MessageHandler):
|
||||
|
||||
privilege = 1
|
||||
|
||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||
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:
|
||||
privilege = 2
|
||||
|
||||
spt = command_text.split(' ')
|
||||
|
||||
@@ -241,7 +241,11 @@ 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(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
reply_list.append(
|
||||
platform_message.Image(
|
||||
url=msg_data['data']['url'], 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']))
|
||||
@@ -286,7 +290,9 @@ 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(base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
image_msg = platform_message.Image(
|
||||
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
yiri_msg_list.append(image_msg)
|
||||
elif msg.type == 'forward':
|
||||
# 暂时不太合理
|
||||
|
||||
@@ -764,7 +764,9 @@ 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(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
element_list.append(
|
||||
platform_message.Image(url=attachment.url, 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(base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, 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(base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -181,7 +181,10 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
|
||||
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
||||
message_components.append(
|
||||
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
|
||||
platform_message.Image(
|
||||
url=file.file_path,
|
||||
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
|
||||
)
|
||||
)
|
||||
|
||||
if message.voice:
|
||||
|
||||
@@ -133,7 +133,9 @@ 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(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
yiri_msg_list.append(
|
||||
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
)
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
@@ -529,6 +529,7 @@ 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
|
||||
@@ -597,6 +598,36 @@ 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,16 +944,21 @@ 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 extra_args:
|
||||
payload.update(extra_args)
|
||||
if request_args:
|
||||
payload.update(request_args)
|
||||
|
||||
rerank_url = f'{base_url}/rerank'
|
||||
if not rerank_url:
|
||||
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
|
||||
@@ -499,14 +499,14 @@ class WorkspaceCollaborationService:
|
||||
return await self._run(operation, session=session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _invitation_lock(self, token_digest: str):
|
||||
"""Serialize one token while retaining only active lock entries."""
|
||||
async def _invitation_lock(self, lock_key: str):
|
||||
"""Serialize one token within workspace scope while retaining only active lock entries."""
|
||||
|
||||
async with self._invitation_locks_guard:
|
||||
entry = self._invitation_locks.get(token_digest)
|
||||
entry = self._invitation_locks.get(lock_key)
|
||||
if entry is None:
|
||||
entry = _InvitationLockEntry(lock=asyncio.Lock())
|
||||
self._invitation_locks[token_digest] = entry
|
||||
self._invitation_locks[lock_key] = 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(token_digest, None)
|
||||
self._invitation_locks.pop(lock_key, None)
|
||||
|
||||
async def revoke_invitation(
|
||||
self,
|
||||
|
||||
@@ -105,6 +105,7 @@ 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.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""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,6 +34,7 @@ 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),
|
||||
@@ -119,6 +120,21 @@ 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,6 +833,44 @@ 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': 'chat',
|
||||
'category': 'rerank',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
@@ -778,6 +778,7 @@ 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."""
|
||||
|
||||
@@ -418,6 +418,63 @@ class TestUserServiceGenerateJwtToken:
|
||||
assert token is not None
|
||||
|
||||
|
||||
|
||||
async def test_admin_owner_token_is_scoped_and_capped_at_five_minutes(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
|
||||
service = UserService(ap)
|
||||
before = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
||||
|
||||
token = await service.generate_jwt_token(
|
||||
'owner@example.com',
|
||||
admin_owner_scope={
|
||||
'actor_account_uuid': 'admin-uuid',
|
||||
'workspace_uuid': 'workspace-uuid',
|
||||
'effective_role': 'owner',
|
||||
},
|
||||
)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
'test_secret',
|
||||
algorithms=['HS256'],
|
||||
options={'verify_aud': False},
|
||||
)
|
||||
|
||||
assert payload['exp'] <= before + 301
|
||||
assert service.get_admin_owner_scope(token) == {
|
||||
'actor_account_uuid': 'admin-uuid',
|
||||
'workspace_uuid': 'workspace-uuid',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
|
||||
legacy_token = jwt.encode(
|
||||
{
|
||||
'user': 'legacy@example.com',
|
||||
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=5),
|
||||
},
|
||||
'test_secret',
|
||||
algorithm='HS256',
|
||||
)
|
||||
assert service.get_admin_owner_scope(legacy_token) is None
|
||||
|
||||
async def test_admin_owner_token_rejects_invalid_scope(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
|
||||
service = UserService(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid admin owner token scope'):
|
||||
await service.generate_jwt_token(
|
||||
'owner@example.com',
|
||||
admin_owner_scope={
|
||||
'actor_account_uuid': 'admin-uuid',
|
||||
'workspace_uuid': 'workspace-uuid',
|
||||
'effective_role': 'member',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestUserServiceVerifyJwtToken:
|
||||
"""Tests for verify_jwt_token method."""
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
|
||||
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
|
||||
logger=SimpleNamespace(info=lambda *args, **kwargs: None),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'space': {
|
||||
@@ -86,6 +87,58 @@ async def test_consumes_valid_workspace_launch_assertion_once():
|
||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
|
||||
|
||||
|
||||
async def test_consumes_admin_owner_launch_once_and_validates_claims():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
now = int(time.time())
|
||||
service = _service(private_key, now=now)
|
||||
claims = _claims(now=now)
|
||||
claims['payload'].update(
|
||||
{
|
||||
'launch_mode': 'admin_owner',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
)
|
||||
token = _sign(private_key, claims)
|
||||
|
||||
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
assert launch == {
|
||||
'account_uuid': ACCOUNT_UUID,
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_mode': 'admin_owner',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
with pytest.raises(SpaceLaunchError, match='already been consumed'):
|
||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
invalid = _claims(now=now)
|
||||
invalid['payload'].update(
|
||||
{
|
||||
'launch_mode': 'admin_owner',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'member',
|
||||
}
|
||||
)
|
||||
with pytest.raises(SpaceLaunchError, match='effective role'):
|
||||
await service.consume_assertion(_sign(private_key, invalid), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
too_long = _claims(now=now)
|
||||
too_long['exp'] = now + 91
|
||||
too_long['payload'].update(
|
||||
{
|
||||
'launch_mode': 'admin_owner',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
)
|
||||
with pytest.raises(SpaceLaunchError, match='lifetime exceeds 90 seconds'):
|
||||
await service.consume_assertion(_sign(private_key, too_long), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
|
||||
async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
now = int(time.time())
|
||||
|
||||
@@ -158,17 +158,21 @@ class TestCommandHandlerReal:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""Admin users get privilege level 2."""
|
||||
"""A per-bot admin from the database is marked as admin in command events."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
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.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
|
||||
|
||||
@@ -176,23 +180,28 @@ 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):
|
||||
"""Non-admin users get privilege level 1."""
|
||||
"""A launcher absent from the per-bot admin table is not an admin."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
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.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
|
||||
|
||||
@@ -200,6 +209,7 @@ 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,6 +1147,46 @@ 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,6 +86,49 @@ 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
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user