mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
feat(tenancy): implement workspace isolation
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import types
|
||||
import typing
|
||||
|
||||
from .context import RequestContext
|
||||
|
||||
|
||||
class WorkspaceRole(enum.StrEnum):
|
||||
OWNER = 'owner'
|
||||
ADMIN = 'admin'
|
||||
DEVELOPER = 'developer'
|
||||
OPERATOR = 'operator'
|
||||
VIEWER = 'viewer'
|
||||
|
||||
|
||||
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'
|
||||
MEMBER_REMOVE = 'member.remove'
|
||||
RESOURCE_VIEW = 'resource.view'
|
||||
RESOURCE_MANAGE = 'resource.manage'
|
||||
RUNTIME_OPERATE = 'runtime.operate'
|
||||
PROVIDER_SECRET_MANAGE = 'provider_secret.manage'
|
||||
API_KEY_MANAGE = 'api_key.manage'
|
||||
AUDIT_VIEW = 'audit.view'
|
||||
DATA_EXPORT = 'data.export'
|
||||
BILLING_LINK_MANAGE = 'billing_link.manage'
|
||||
|
||||
|
||||
_VIEW_PERMISSIONS = {
|
||||
Permission.WORKSPACE_VIEW,
|
||||
Permission.MEMBER_VIEW,
|
||||
Permission.RESOURCE_VIEW,
|
||||
}
|
||||
|
||||
_ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
||||
{
|
||||
WorkspaceRole.OWNER: frozenset(Permission),
|
||||
WorkspaceRole.ADMIN: frozenset(
|
||||
permission
|
||||
for permission in Permission
|
||||
if permission
|
||||
not in {
|
||||
Permission.WORKSPACE_DELETE,
|
||||
Permission.OWNER_TRANSFER,
|
||||
Permission.BILLING_LINK_MANAGE,
|
||||
}
|
||||
),
|
||||
WorkspaceRole.DEVELOPER: frozenset(
|
||||
_VIEW_PERMISSIONS
|
||||
| {
|
||||
Permission.RESOURCE_MANAGE,
|
||||
Permission.RUNTIME_OPERATE,
|
||||
Permission.PROVIDER_SECRET_MANAGE,
|
||||
}
|
||||
),
|
||||
WorkspaceRole.OPERATOR: frozenset(_VIEW_PERMISSIONS | {Permission.RUNTIME_OPERATE}),
|
||||
WorkspaceRole.VIEWER: frozenset(_VIEW_PERMISSIONS),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
"""Base class for errors that map to an HTTP authorization response."""
|
||||
|
||||
status_code = 403
|
||||
error_code = 'forbidden'
|
||||
|
||||
|
||||
class WorkspaceRequiredError(AuthorizationError):
|
||||
status_code = 400
|
||||
error_code = 'workspace_required'
|
||||
|
||||
|
||||
class PermissionDeniedError(AuthorizationError):
|
||||
error_code = 'permission_denied'
|
||||
|
||||
def __init__(self, permission: str) -> None:
|
||||
super().__init__(f'Missing Workspace permission: {permission}')
|
||||
self.permission = permission
|
||||
|
||||
|
||||
class EditionLimitError(AuthorizationError):
|
||||
error_code = 'edition_limit'
|
||||
|
||||
|
||||
def permissions_for_role(role: str | WorkspaceRole) -> frozenset[str]:
|
||||
"""Return the canonical fixed permissions for a Workspace role."""
|
||||
|
||||
try:
|
||||
parsed_role = WorkspaceRole(role)
|
||||
except ValueError:
|
||||
return frozenset()
|
||||
return frozenset(permission.value for permission in _ROLE_PERMISSIONS[parsed_role])
|
||||
|
||||
|
||||
def has_permission(ctx: RequestContext, permission: str | Permission) -> bool:
|
||||
"""Return whether the context contains one effective permission."""
|
||||
|
||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
||||
return permission_value in ctx.workspace.permissions
|
||||
|
||||
|
||||
def require_permission(ctx: RequestContext, permission: str | Permission) -> None:
|
||||
"""Raise a stable authorization error when a permission is missing."""
|
||||
|
||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
||||
if not has_permission(ctx, permission_value):
|
||||
raise PermissionDeniedError(permission_value)
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
|
||||
|
||||
class PrincipalType(enum.StrEnum):
|
||||
"""Kinds of authenticated principals accepted by LangBot."""
|
||||
|
||||
ACCOUNT = 'account'
|
||||
API_KEY = 'api_key'
|
||||
SYSTEM = 'system'
|
||||
PUBLIC_BOT = 'public_bot'
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class PrincipalContext:
|
||||
"""Authenticated identity before Workspace authorization is applied."""
|
||||
|
||||
principal_type: PrincipalType
|
||||
account_uuid: str | None = None
|
||||
api_key_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class WorkspaceContext:
|
||||
"""Workspace membership and effective permissions for one request."""
|
||||
|
||||
workspace_uuid: str
|
||||
membership_uuid: str | None
|
||||
role: str | None
|
||||
permissions: frozenset[str]
|
||||
membership_revision: int = 0
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class RequestContext:
|
||||
"""Trusted authorization context passed to HTTP services."""
|
||||
|
||||
instance_uuid: str
|
||||
placement_generation: int
|
||||
request_id: str
|
||||
auth_type: str
|
||||
principal: PrincipalContext
|
||||
workspace: WorkspaceContext
|
||||
entitlement_revision: int = 0
|
||||
|
||||
@property
|
||||
def workspace_uuid(self) -> str:
|
||||
"""Return the selected Workspace UUID."""
|
||||
|
||||
return self.workspace.workspace_uuid
|
||||
|
||||
@property
|
||||
def account_uuid(self) -> str | None:
|
||||
"""Return the Account UUID when the principal is an Account."""
|
||||
|
||||
return self.principal.account_uuid
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class ExecutionContext:
|
||||
"""Workspace context propagated to asynchronous and runtime work."""
|
||||
|
||||
instance_uuid: str
|
||||
workspace_uuid: str
|
||||
placement_generation: int
|
||||
bot_uuid: str | None = None
|
||||
pipeline_uuid: str | None = None
|
||||
query_uuid: str | None = None
|
||||
trigger_principal: PrincipalContext | None = None
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
cls,
|
||||
ctx: RequestContext,
|
||||
*,
|
||||
bot_uuid: str | None = None,
|
||||
pipeline_uuid: str | None = None,
|
||||
query_uuid: str | None = None,
|
||||
) -> ExecutionContext:
|
||||
"""Create a runtime context without losing the tenant generation."""
|
||||
|
||||
return cls(
|
||||
instance_uuid=ctx.instance_uuid,
|
||||
workspace_uuid=ctx.workspace_uuid,
|
||||
placement_generation=ctx.placement_generation,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
trigger_principal=ctx.principal,
|
||||
)
|
||||
@@ -5,9 +5,18 @@ import typing
|
||||
import enum
|
||||
import quart
|
||||
import traceback
|
||||
import inspect
|
||||
import uuid
|
||||
from quart.typing import RouteCallable
|
||||
|
||||
from ....core import app
|
||||
from ....utils import constants
|
||||
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
# Maximum file upload size limit (10MB)
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
@@ -33,6 +42,7 @@ class AuthType(enum.Enum):
|
||||
"""Authentication type"""
|
||||
|
||||
NONE = 'none'
|
||||
ACCOUNT_TOKEN = 'account-token'
|
||||
USER_TOKEN = 'user-token'
|
||||
API_KEY = 'api-key'
|
||||
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||
@@ -43,11 +53,11 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
path: str
|
||||
|
||||
ap: app.Application
|
||||
ap: Application
|
||||
|
||||
quart_app: quart.Quart
|
||||
|
||||
def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None:
|
||||
def __init__(self, ap: Application, quart_app: quart.Quart) -> None:
|
||||
self.ap = ap
|
||||
self.quart_app = quart_app
|
||||
|
||||
@@ -59,16 +69,37 @@ class RouterGroup(abc.ABC):
|
||||
self,
|
||||
rule: str,
|
||||
auth_type: AuthType = AuthType.USER_TOKEN,
|
||||
permission: Permission | str | None = None,
|
||||
**options: typing.Any,
|
||||
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
|
||||
"""Register a route"""
|
||||
|
||||
if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
|
||||
raise ValueError('Account-token routes cannot declare Workspace permissions')
|
||||
|
||||
def decorator(f: RouteCallable) -> RouteCallable:
|
||||
nonlocal rule
|
||||
rule = self.path + rule
|
||||
|
||||
async def handler_error(*args, **kwargs):
|
||||
if auth_type == AuthType.USER_TOKEN:
|
||||
if auth_type == AuthType.ACCOUNT_TOKEN:
|
||||
authorization = quart.request.headers.get('Authorization', '')
|
||||
if not authorization.startswith('Bearer '):
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
token = authorization.removeprefix('Bearer ')
|
||||
if not token:
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
|
||||
try:
|
||||
_, user_email = await self._authenticate_account(token)
|
||||
# Account-token routes deliberately stop before Workspace
|
||||
# selection. They may bootstrap a selector, but cannot
|
||||
# receive RequestContext or enforce Workspace permissions.
|
||||
self._inject_handler_context(f, kwargs, user_email, None)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN:
|
||||
# get token from Authorization header
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
|
||||
@@ -76,18 +107,15 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
|
||||
try:
|
||||
user_email = await self.ap.user_service.verify_jwt_token(token)
|
||||
|
||||
# check if this account exists
|
||||
user = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if not user:
|
||||
return self.http_status(401, -1, 'User not found')
|
||||
|
||||
# check if f accepts user_email parameter
|
||||
if 'user_email' in f.__code__.co_varnames:
|
||||
kwargs['user_email'] = user_email
|
||||
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')
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, user_email, request_context)
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
return self._auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.API_KEY:
|
||||
# get API key from Authorization header or X-API-Key header
|
||||
@@ -101,11 +129,12 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(401, -1, 'No valid API key provided')
|
||||
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid API key')
|
||||
request_context = await self._authenticate_api_key(api_key, auth_type)
|
||||
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.http_status(401, -1, str(e))
|
||||
return self._auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||
# Try API key first (check X-API-Key header)
|
||||
@@ -114,11 +143,12 @@ class RouterGroup(abc.ABC):
|
||||
if api_key:
|
||||
# API key authentication
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid API key')
|
||||
request_context = await self._authenticate_api_key(api_key, auth_type)
|
||||
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.http_status(401, -1, str(e))
|
||||
return self._auth_error_response(e)
|
||||
else:
|
||||
# Try user token authentication (Authorization header)
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
@@ -129,35 +159,56 @@ class RouterGroup(abc.ABC):
|
||||
)
|
||||
|
||||
try:
|
||||
user_email = await self.ap.user_service.verify_jwt_token(token)
|
||||
|
||||
# check if this account exists
|
||||
user = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if not user:
|
||||
return self.http_status(401, -1, 'User not found')
|
||||
|
||||
# check if f accepts user_email parameter
|
||||
if 'user_email' in f.__code__.co_varnames:
|
||||
kwargs['user_email'] = user_email
|
||||
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')
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, user_email, request_context)
|
||||
except (AuthorizationError, WorkspaceNotFoundError, MembershipPermissionError) as e:
|
||||
# Authentication succeeded and authorization was
|
||||
# evaluated. Do not reinterpret a denied user token
|
||||
# as an API key, which would mask the stable 403/404.
|
||||
return self._auth_error_response(e)
|
||||
except Exception:
|
||||
# If user token fails, maybe it's an API key in Authorization header
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(token)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid authentication credentials')
|
||||
request_context = await self._authenticate_api_key(token, auth_type)
|
||||
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.http_status(401, -1, str(e))
|
||||
return self._auth_error_response(e)
|
||||
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
except Exception as e: # 自动 500
|
||||
traceback.print_exc()
|
||||
# return self.http_status(500, -2, str(e))
|
||||
return self.http_status(500, -2, str(e))
|
||||
if isinstance(e, AuthorizationError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, WorkspaceNotFoundError):
|
||||
return self.http_status(404, 'resource_not_found', 'Resource not found')
|
||||
if isinstance(e, MembershipPermissionError):
|
||||
return self.http_status(403, e.code, str(e))
|
||||
if isinstance(e, WorkspaceCollaborationError):
|
||||
return self.http_status(400, e.code, str(e))
|
||||
request_id = self.request_id()
|
||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||
logger.error(
|
||||
f'Unhandled HTTP error request_id={request_id} '
|
||||
f'method={quart.request.method} path={quart.request.path}\n{traceback.format_exc()}'
|
||||
)
|
||||
return self.internal_error_response(request_id)
|
||||
|
||||
new_f = handler_error
|
||||
new_f.__name__ = (self.name + rule).replace('/', '__')
|
||||
# Quart/Flask requires a unique endpoint name even when the same URL
|
||||
# intentionally has separate handlers for different HTTP methods.
|
||||
# Include the method set so CRUD routes can declare distinct
|
||||
# permissions without colliding during application startup.
|
||||
methods = options.get('methods') or ['GET']
|
||||
method_suffix = '__'.join(sorted(str(method).upper() for method in methods))
|
||||
new_f.__name__ = (self.name + rule + '__' + method_suffix).replace('/', '__')
|
||||
new_f.__doc__ = f.__doc__
|
||||
|
||||
self.quart_app.route(rule, **options)(new_f)
|
||||
@@ -165,6 +216,165 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
return decorator
|
||||
|
||||
async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]:
|
||||
account: typing.Any = None
|
||||
resolver = getattr(self.ap.user_service, 'get_authenticated_account', None)
|
||||
if callable(resolver):
|
||||
resolved = resolver(token)
|
||||
if inspect.isawaitable(resolved):
|
||||
account = await resolved
|
||||
|
||||
if isinstance(account, str) or account is None:
|
||||
user_email = account or await self.ap.user_service.verify_jwt_token(token)
|
||||
account = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if account is None:
|
||||
raise ValueError('User not found')
|
||||
return account, account.user
|
||||
|
||||
async def _resolve_account_context(
|
||||
self,
|
||||
account: typing.Any,
|
||||
auth_type: AuthType,
|
||||
) -> RequestContext | None:
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
# Compatibility for isolated controller tests that do not wire the tenancy kernel.
|
||||
if collaboration_service is None or not isinstance(account_uuid, str):
|
||||
return None
|
||||
|
||||
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
|
||||
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=access.execution.instance_uuid,
|
||||
placement_generation=access.execution.placement_generation,
|
||||
request_id=self.request_id(),
|
||||
auth_type=auth_type.value,
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.ACCOUNT,
|
||||
account_uuid=account_uuid,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=access.workspace.uuid,
|
||||
membership_uuid=access.membership.uuid,
|
||||
role=access.membership.role,
|
||||
permissions=permissions_for_role(access.membership.role),
|
||||
membership_revision=access.membership.projection_revision,
|
||||
),
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
quart.g.workspace_membership = access.membership
|
||||
return request_context
|
||||
|
||||
async def _authenticate_api_key(self, api_key: str, auth_type: AuthType) -> RequestContext:
|
||||
authenticator = getattr(self.ap.apikey_service, 'authenticate_api_key', None)
|
||||
if callable(authenticator):
|
||||
authenticated = authenticator(api_key)
|
||||
if inspect.isawaitable(authenticated):
|
||||
identity = await authenticated
|
||||
if identity is not None:
|
||||
request_context = RequestContext(
|
||||
instance_uuid=identity.instance_uuid,
|
||||
placement_generation=identity.placement_generation,
|
||||
request_id=self.request_id(),
|
||||
auth_type=auth_type.value,
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.API_KEY,
|
||||
api_key_uuid=identity.api_key_uuid,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=identity.workspace_uuid,
|
||||
membership_uuid=None,
|
||||
role=None,
|
||||
permissions=identity.permissions,
|
||||
),
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
return request_context
|
||||
|
||||
if not await self.ap.apikey_service.verify_api_key(api_key):
|
||||
raise ValueError('Invalid API key')
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
if workspace_service is None:
|
||||
raise ValueError('API key Workspace binding is unavailable')
|
||||
binding = await workspace_service.get_local_execution_binding()
|
||||
request_context = RequestContext(
|
||||
instance_uuid=binding.instance_uuid or constants.instance_id,
|
||||
placement_generation=binding.placement_generation,
|
||||
request_id=self.request_id(),
|
||||
auth_type=auth_type.value,
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.API_KEY,
|
||||
api_key_uuid='legacy-oss-api-key',
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
membership_uuid=None,
|
||||
role=None,
|
||||
permissions=frozenset(item.value for item in Permission),
|
||||
),
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
return request_context
|
||||
|
||||
@staticmethod
|
||||
def _inject_handler_context(
|
||||
handler: RouteCallable,
|
||||
kwargs: dict[str, typing.Any],
|
||||
user_email: str | None,
|
||||
request_context: RequestContext | None,
|
||||
) -> None:
|
||||
parameters = handler.__code__.co_varnames
|
||||
if user_email is not None and 'user_email' in parameters:
|
||||
kwargs['user_email'] = user_email
|
||||
if request_context is not None:
|
||||
if 'request_context' in parameters:
|
||||
kwargs['request_context'] = request_context
|
||||
elif 'ctx' in parameters:
|
||||
kwargs['ctx'] = request_context
|
||||
|
||||
def _auth_error_response(self, error: Exception) -> typing.Any:
|
||||
if isinstance(error, AuthorizationError):
|
||||
return self.http_status(error.status_code, error.error_code, str(error))
|
||||
if isinstance(error, WorkspaceNotFoundError):
|
||||
return self.http_status(404, 'resource_not_found', 'Resource not found')
|
||||
if isinstance(error, MembershipPermissionError):
|
||||
return self.http_status(403, error.code, str(error))
|
||||
request_id = self.request_id()
|
||||
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
||||
logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
|
||||
return self.http_status(
|
||||
401,
|
||||
'invalid_authentication',
|
||||
'Invalid authentication credentials',
|
||||
)
|
||||
|
||||
def request_id(self) -> str:
|
||||
"""Return one stable request ID for authentication, logs, and errors."""
|
||||
|
||||
request_context = getattr(quart.g, 'request_context', None)
|
||||
request_id = getattr(request_context, 'request_id', None) or getattr(quart.g, 'request_id', None)
|
||||
if not request_id:
|
||||
candidate = str(quart.request.headers.get('X-Request-Id') or '').strip()
|
||||
if not candidate or len(candidate) > 128 or any(ord(char) < 32 for char in candidate):
|
||||
candidate = str(uuid.uuid4())
|
||||
request_id = candidate
|
||||
quart.g.request_id = request_id
|
||||
return str(request_id)
|
||||
|
||||
def internal_error_response(self, request_id: str | None = None) -> typing.Tuple[quart.Response, int]:
|
||||
"""Return a stable 500 response without exposing the underlying exception."""
|
||||
|
||||
resolved_request_id = request_id or self.request_id()
|
||||
response = quart.jsonify(
|
||||
{
|
||||
'code': 'internal_error',
|
||||
'msg': 'Internal server error',
|
||||
'request_id': resolved_request_id,
|
||||
}
|
||||
)
|
||||
response.headers['X-Request-Id'] = resolved_request_id
|
||||
return response, 500
|
||||
|
||||
def success(self, data: typing.Any = None) -> quart.Response:
|
||||
"""Return a 200 response"""
|
||||
return quart.jsonify(
|
||||
@@ -175,7 +385,7 @@ class RouterGroup(abc.ABC):
|
||||
}
|
||||
)
|
||||
|
||||
def fail(self, code: int, msg: str) -> quart.Response:
|
||||
def fail(self, code: int | str, msg: str) -> quart.Response:
|
||||
"""Return an error response"""
|
||||
|
||||
return quart.jsonify(
|
||||
@@ -185,6 +395,6 @@ class RouterGroup(abc.ABC):
|
||||
}
|
||||
)
|
||||
|
||||
def http_status(self, status: int, code: int, msg: str) -> typing.Tuple[quart.Response, int]:
|
||||
def http_status(self, status: int, code: int | str, msg: str) -> typing.Tuple[quart.Response, int]:
|
||||
"""返回一个指定状态码的响应"""
|
||||
return (self.fail(code, msg), status)
|
||||
|
||||
@@ -1,43 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('apikeys', '/api/v1/apikeys')
|
||||
class ApiKeysRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
keys = await self.ap.apikey_service.get_api_keys()
|
||||
return self.success(data={'keys': keys})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
description = json_data.get('description', '')
|
||||
@self.route('', methods=['GET'], permission=Permission.API_KEY_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
keys = await self.ap.apikey_service.get_api_keys(request_context)
|
||||
return self.success(data={'keys': keys})
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
@self.route('', methods=['POST'], permission=Permission.API_KEY_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
expires_at = json_data.get('expires_at')
|
||||
parsed_expiry = None
|
||||
if expires_at:
|
||||
try:
|
||||
parsed_expiry = datetime.datetime.fromisoformat(str(expires_at).replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return self.http_status(400, 'invalid_expiry', 'Invalid API key expiry')
|
||||
try:
|
||||
key = await self.ap.apikey_service.create_api_key(
|
||||
request_context,
|
||||
json_data.get('name', ''),
|
||||
json_data.get('description', ''),
|
||||
scopes=json_data.get('scopes'),
|
||||
expires_at=parsed_expiry,
|
||||
)
|
||||
except ValueError as error:
|
||||
return self.http_status(400, 'invalid_api_key', str(error))
|
||||
return self.success(data={'key': key})
|
||||
|
||||
key = await self.ap.apikey_service.create_api_key(name, description)
|
||||
return self.success(data={'key': key})
|
||||
@self.route('/<int:key_id>', methods=['GET'], permission=Permission.API_KEY_MANAGE)
|
||||
async def _(key_id: int, request_context: RequestContext) -> str:
|
||||
key = await self.ap.apikey_service.get_api_key(request_context, key_id)
|
||||
if key is None:
|
||||
return self.http_status(404, 'resource_not_found', 'API key not found')
|
||||
return self.success(data={'key': key})
|
||||
|
||||
@self.route('/<int:key_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(key_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
key = await self.ap.apikey_service.get_api_key(key_id)
|
||||
if key is None:
|
||||
return self.http_status(404, -1, 'API key not found')
|
||||
return self.success(data={'key': key})
|
||||
@self.route('/<int:key_id>', methods=['PUT'], permission=Permission.API_KEY_MANAGE)
|
||||
async def _(key_id: int, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
await self.ap.apikey_service.update_api_key(
|
||||
request_context,
|
||||
key_id,
|
||||
json_data.get('name'),
|
||||
json_data.get('description'),
|
||||
)
|
||||
except ValueError as error:
|
||||
return self.http_status(400, 'invalid_api_key', str(error))
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
description = json_data.get('description')
|
||||
|
||||
await self.ap.apikey_service.update_api_key(key_id, name, description)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.apikey_service.delete_api_key(key_id)
|
||||
return self.success()
|
||||
@self.route('/<int:key_id>', methods=['DELETE'], permission=Permission.API_KEY_MANAGE)
|
||||
async def _(key_id: int, request_context: RequestContext) -> str:
|
||||
await self.ap.apikey_service.delete_api_key(request_context, key_id)
|
||||
return self.success()
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
from .box_visibility import should_hide_box_runtime_status
|
||||
|
||||
@@ -9,18 +11,33 @@ from .box_visibility import should_hide_box_runtime_status
|
||||
@group.group_class('box', '/api/v1/box')
|
||||
class BoxRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
status = await self.ap.box_service.get_status()
|
||||
@self.route(
|
||||
'/status',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
status = await self.ap.box_service.get_status(request_context)
|
||||
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
|
||||
return self.success(data=status)
|
||||
|
||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
sessions = await self.ap.box_service.get_sessions()
|
||||
@self.route(
|
||||
'/sessions',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
sessions = await self.ap.box_service.get_sessions(request_context)
|
||||
return self.success(data=sessions)
|
||||
|
||||
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
errors = self.ap.box_service.get_recent_errors()
|
||||
@self.route(
|
||||
'/errors',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
errors = self.ap.box_service.get_recent_errors(request_context)
|
||||
return self.success(data=errors)
|
||||
|
||||
@@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...service.secrets import redact_secrets
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -11,12 +14,19 @@ class ExtensionsRouterGroup(group.RouterGroup):
|
||||
"""Unified API for installed extensions (plugins, MCP servers, skills)."""
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> quart.Response:
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> quart.Response:
|
||||
if self.ap.plugin_connector.is_enable_plugin:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
plugins, mcp_servers, skills = await asyncio.gather(
|
||||
self.ap.plugin_connector.list_plugins(),
|
||||
self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True),
|
||||
self.ap.skill_service.list_skills(),
|
||||
self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True),
|
||||
self.ap.skill_service.list_skills(request_context),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
@@ -39,7 +49,7 @@ class ExtensionsRouterGroup(group.RouterGroup):
|
||||
extensions: list[dict] = []
|
||||
if isinstance(plugins, list):
|
||||
for plugin in plugins:
|
||||
extensions.append({'type': 'plugin', 'plugin': plugin})
|
||||
extensions.append({'type': 'plugin', 'plugin': redact_secrets(plugin)})
|
||||
if isinstance(mcp_servers, list):
|
||||
for server in mcp_servers:
|
||||
extensions.append({'type': 'mcp', 'server': server})
|
||||
|
||||
@@ -7,29 +7,48 @@ import asyncio
|
||||
|
||||
import quart.datastructures
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
|
||||
|
||||
def _storage_owner(context: RequestContext) -> str:
|
||||
if context.principal.account_uuid:
|
||||
return f'account:{context.principal.account_uuid}'
|
||||
if context.principal.api_key_uuid:
|
||||
return f'api-key:{context.principal.api_key_uuid}'
|
||||
return f'principal:{context.principal.principal_type.value}'
|
||||
|
||||
|
||||
@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:
|
||||
if '..' in image_key or '\\' in image_key:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
if image_bytes is None:
|
||||
image_bytes = await self.ap.storage_mgr.resolve_public_object(
|
||||
image_key,
|
||||
expected_owner_type='bot_log',
|
||||
)
|
||||
if image_bytes is None:
|
||||
return quart.Response(status=404)
|
||||
|
||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||
return quart.Response(status=404)
|
||||
|
||||
image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key)
|
||||
mime_type = mimetypes.guess_type(image_key)[0]
|
||||
if mime_type is None:
|
||||
mime_type = 'image/jpeg'
|
||||
|
||||
return quart.Response(image_bytes, mimetype=mime_type)
|
||||
|
||||
@self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def upload_image() -> quart.Response:
|
||||
@self.route(
|
||||
'/images',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def upload_image(request_context: RequestContext) -> quart.Response:
|
||||
request = quart.request
|
||||
|
||||
# Check file size limit before reading the file
|
||||
@@ -66,18 +85,29 @@ class FilesRouterGroup(group.RouterGroup):
|
||||
if '/' in file_name or '\\' in file_name:
|
||||
return self.fail(400, 'File name contains invalid characters')
|
||||
|
||||
file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||
logical_key = f'{uuid.uuid4()}.{extension}'
|
||||
|
||||
# save file to storage
|
||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||
file_key = await self.ap.storage_mgr.save_scoped(
|
||||
request_context,
|
||||
owner_type='upload_image',
|
||||
owner=_storage_owner(request_context),
|
||||
key=logical_key,
|
||||
value=file_bytes,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'file_key': file_key,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def upload_document() -> quart.Response:
|
||||
@self.route(
|
||||
'/documents',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def upload_document(request_context: RequestContext) -> quart.Response:
|
||||
request = quart.request
|
||||
|
||||
# Check file size limit before reading the file
|
||||
@@ -110,12 +140,18 @@ class FilesRouterGroup(group.RouterGroup):
|
||||
if '/' in file_name or '\\' in file_name:
|
||||
return self.fail(400, 'File name contains invalid characters')
|
||||
|
||||
file_key = file_name + '_' + str(uuid.uuid4())[:8]
|
||||
logical_key = str(uuid.uuid4())
|
||||
if extension:
|
||||
file_key += '.' + extension
|
||||
logical_key += '.' + extension
|
||||
|
||||
# save file to storage
|
||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||
file_key = await self.ap.storage_mgr.save_scoped(
|
||||
request_context,
|
||||
owner_type='upload_document',
|
||||
owner=_storage_owner(request_context),
|
||||
key=logical_key,
|
||||
value=file_bytes,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'file_id': file_key,
|
||||
|
||||
@@ -1,100 +1,146 @@
|
||||
import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('knowledge_base', '/api/v1/knowledge/bases')
|
||||
class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def handle_knowledge_bases() -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()
|
||||
return self.success(data={'bases': knowledge_bases})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def handle_knowledge_bases(request_context: RequestContext) -> quart.Response:
|
||||
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases(
|
||||
request_context,
|
||||
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
|
||||
)
|
||||
return self.success(data={'bases': knowledge_bases})
|
||||
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data)
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
return self.success(data={'uuid': knowledge_base_uuid})
|
||||
|
||||
return self.http_status(405, -1, 'Method not allowed')
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def create_knowledge_base(request_context: RequestContext) -> quart.Response:
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(
|
||||
request_context,
|
||||
json_data,
|
||||
)
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
return self.success(data={'uuid': knowledge_base_uuid})
|
||||
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>',
|
||||
methods=['GET', 'DELETE', 'PUT'],
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
knowledge_base = await self.ap.knowledge_service.get_knowledge_base(knowledge_base_uuid)
|
||||
async def get_specific_knowledge_base(
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
knowledge_base = await self.ap.knowledge_service.get_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
|
||||
)
|
||||
if knowledge_base is None:
|
||||
return self.http_status(404, 'resource_not_found', 'knowledge base not found')
|
||||
return self.success(data={'base': knowledge_base})
|
||||
|
||||
if knowledge_base is None:
|
||||
return self.http_status(404, -1, 'knowledge base not found')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'base': knowledge_base,
|
||||
}
|
||||
)
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>',
|
||||
methods=['DELETE', 'PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def mutate_specific_knowledge_base(
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data)
|
||||
await self.ap.knowledge_service.update_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
json_data,
|
||||
)
|
||||
return self.success(data={'uuid': knowledge_base_uuid})
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid)
|
||||
return self.success({})
|
||||
await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid)
|
||||
return self.success({})
|
||||
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/files',
|
||||
methods=['GET', 'POST'],
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
files = await self.ap.knowledge_service.get_files_by_knowledge_base(knowledge_base_uuid)
|
||||
return self.success(
|
||||
data={
|
||||
'files': files,
|
||||
}
|
||||
)
|
||||
async def get_knowledge_base_files(
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> str:
|
||||
files = await self.ap.knowledge_service.get_files_by_knowledge_base(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
)
|
||||
return self.success(data={'files': files})
|
||||
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
file_id = json_data.get('file_id')
|
||||
if not file_id:
|
||||
return self.http_status(400, -1, 'File ID is required')
|
||||
|
||||
parser_plugin_id = json_data.get('parser_plugin_id')
|
||||
|
||||
# 调用服务层方法将文件与知识库关联
|
||||
task_id = await self.ap.knowledge_service.store_file(
|
||||
knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id
|
||||
)
|
||||
return self.success(
|
||||
{
|
||||
'task_id': task_id,
|
||||
}
|
||||
)
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/files',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def add_knowledge_base_file(
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> str:
|
||||
json_data = await quart.request.json
|
||||
file_id = json_data.get('file_id')
|
||||
if not file_id:
|
||||
return self.http_status(400, -1, 'File ID is required')
|
||||
parser_plugin_id = json_data.get('parser_plugin_id')
|
||||
task_id = await self.ap.knowledge_service.store_file(
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
file_id,
|
||||
parser_plugin_id=parser_plugin_id,
|
||||
)
|
||||
return self.success({'task_id': task_id})
|
||||
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/files/<file_id>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:
|
||||
await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)
|
||||
async def delete_specific_file_in_kb(
|
||||
file_id: str,
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> str:
|
||||
await self.ap.knowledge_service.delete_file(request_context, knowledge_base_uuid, file_id)
|
||||
return self.success({})
|
||||
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/retrieve',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:
|
||||
async def retrieve_knowledge_base(
|
||||
knowledge_base_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> str:
|
||||
json_data = await quart.request.json
|
||||
query = json_data.get('query')
|
||||
|
||||
@@ -104,6 +150,9 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
# Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters)
|
||||
retrieval_settings = json_data.get('retrieval_settings', {})
|
||||
results = await self.ap.knowledge_service.retrieve_knowledge_base(
|
||||
knowledge_base_uuid, query, retrieval_settings
|
||||
request_context,
|
||||
knowledge_base_uuid,
|
||||
query,
|
||||
retrieval_settings,
|
||||
)
|
||||
return self.success(data={'results': results})
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import quart
|
||||
from urllib.parse import unquote
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('knowledge_engines', '/api/v1/knowledge/engines')
|
||||
class KnowledgeEnginesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def list_knowledge_engines() -> quart.Response:
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def list_knowledge_engines(request_context: RequestContext) -> quart.Response:
|
||||
"""List all available Knowledge Engines from plugins.
|
||||
|
||||
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
|
||||
This is used by the frontend to render the knowledge base creation wizard.
|
||||
"""
|
||||
engines = await self.ap.knowledge_service.list_knowledge_engines()
|
||||
engines = await self.ap.knowledge_service.list_knowledge_engines(request_context)
|
||||
return self.success(data={'engines': engines})
|
||||
|
||||
@self.route(
|
||||
'/<path:plugin_id>/creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'/<path:plugin_id>/creation-schema',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def get_engine_creation_schema(plugin_id: str) -> quart.Response:
|
||||
async def get_engine_creation_schema(
|
||||
plugin_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
"""Get creation settings schema for a specific Knowledge Engine.
|
||||
|
||||
plugin_id is in 'author/name' format, captured via <path:> converter.
|
||||
@@ -27,13 +41,19 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup):
|
||||
plugin_id = unquote(plugin_id)
|
||||
if '/' not in plugin_id:
|
||||
return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')
|
||||
schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id)
|
||||
schema = await self.ap.knowledge_service.get_engine_creation_schema(request_context, plugin_id)
|
||||
return self.success(data={'schema': schema})
|
||||
|
||||
@self.route(
|
||||
'/<path:plugin_id>/retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'/<path:plugin_id>/retrieval-schema',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response:
|
||||
async def get_engine_retrieval_schema(
|
||||
plugin_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
"""Get retrieval settings schema for a specific Knowledge Engine.
|
||||
|
||||
plugin_id is in 'author/name' format, captured via <path:> converter.
|
||||
@@ -41,5 +61,5 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup):
|
||||
plugin_id = unquote(plugin_id)
|
||||
if '/' not in plugin_id:
|
||||
return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')
|
||||
schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id)
|
||||
schema = await self.ap.knowledge_service.get_engine_retrieval_schema(request_context, plugin_id)
|
||||
return self.success(data={'schema': schema})
|
||||
|
||||
@@ -6,8 +6,11 @@ import quart
|
||||
import sqlalchemy
|
||||
|
||||
from ... import group
|
||||
from ....authz import Permission
|
||||
from ....context import ExecutionContext, RequestContext
|
||||
from ......core import taskmgr
|
||||
from ......entity.persistence import metadata as persistence_metadata
|
||||
from ......workspace.errors import WorkspaceError, WorkspaceNotFoundError
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
|
||||
@@ -34,21 +37,49 @@ EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
|
||||
|
||||
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
|
||||
class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
async def _get_migration_flag(self) -> bool:
|
||||
async def _require_local_migration_context(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
) -> ExecutionContext:
|
||||
"""Fence legacy-table migration to the OSS singleton Workspace.
|
||||
|
||||
The backup tables predate Workspace scoping and are deliberately
|
||||
instance-global. A cloud projection must therefore never be allowed
|
||||
to inspect or restore them, even when it has a valid execution lease.
|
||||
"""
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_local_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
except WorkspaceNotFoundError:
|
||||
raise
|
||||
except WorkspaceError as exc:
|
||||
raise WorkspaceNotFoundError('RAG migration is unavailable') from exc
|
||||
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise WorkspaceNotFoundError('RAG migration is unavailable')
|
||||
return ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
|
||||
async def _get_migration_flag(self, execution_context: ExecutionContext) -> bool:
|
||||
"""Check if rag_plugin_migration_needed flag is set."""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_metadata.Metadata).where(
|
||||
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
|
||||
)
|
||||
sqlalchemy.select(persistence_metadata.WorkspaceMetadata.value)
|
||||
.where(persistence_metadata.WorkspaceMetadata.workspace_uuid == execution_context.workspace_uuid)
|
||||
.where(persistence_metadata.WorkspaceMetadata.key == 'rag_plugin_migration_needed')
|
||||
)
|
||||
row = result.first()
|
||||
return row is not None and row.value == 'true'
|
||||
return result.scalar_one_or_none() == 'true'
|
||||
|
||||
async def _set_migration_flag(self, value: str):
|
||||
async def _set_migration_flag(self, execution_context: ExecutionContext, value: str):
|
||||
"""Set rag_plugin_migration_needed flag."""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_metadata.Metadata)
|
||||
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
|
||||
sqlalchemy.update(persistence_metadata.WorkspaceMetadata)
|
||||
.where(persistence_metadata.WorkspaceMetadata.workspace_uuid == execution_context.workspace_uuid)
|
||||
.where(persistence_metadata.WorkspaceMetadata.key == 'rag_plugin_migration_needed')
|
||||
.values(value=value)
|
||||
)
|
||||
|
||||
@@ -70,7 +101,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
return result.first() is not None
|
||||
|
||||
async def _install_plugin_from_marketplace(
|
||||
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
plugin_id: str,
|
||||
task_context: taskmgr.TaskContext,
|
||||
space_url: str,
|
||||
) -> None:
|
||||
"""Install a single plugin from the marketplace."""
|
||||
p_author, p_name = plugin_id.split('/', 1)
|
||||
@@ -85,6 +120,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
if not p_version:
|
||||
raise Exception(f'Could not determine latest version for {plugin_id}')
|
||||
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
{
|
||||
@@ -96,8 +132,15 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
)
|
||||
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
|
||||
|
||||
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
|
||||
async def _execute_rag_migration(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
task_context: taskmgr.TaskContext,
|
||||
install_plugin: bool = True,
|
||||
):
|
||||
"""Execute RAG migration: install required plugins and restore backup data."""
|
||||
execution_context = await self._require_local_migration_context(execution_context)
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
warnings = []
|
||||
|
||||
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
|
||||
@@ -127,7 +170,14 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
|
||||
for plugin_id in needed_plugins:
|
||||
try:
|
||||
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
|
||||
await self._install_plugin_from_marketplace(
|
||||
execution_context,
|
||||
plugin_id,
|
||||
task_context,
|
||||
space_url,
|
||||
)
|
||||
except WorkspaceNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
|
||||
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
|
||||
@@ -141,8 +191,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
engine_id_set: set[str] = set()
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
||||
engine_id_set = {e.get('plugin_id') for e in engines}
|
||||
except WorkspaceNotFoundError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if all(pid in engine_id_set for pid in needed_plugins):
|
||||
@@ -158,8 +211,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
try:
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
||||
engine_id_set = {e.get('plugin_id') for e in engines}
|
||||
except WorkspaceNotFoundError:
|
||||
raise
|
||||
except Exception:
|
||||
engine_id_set = set()
|
||||
|
||||
@@ -189,12 +245,13 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'INSERT INTO knowledge_bases '
|
||||
'(uuid, name, description, emoji, created_at, updated_at, '
|
||||
'(uuid, workspace_uuid, name, description, emoji, created_at, updated_at, '
|
||||
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
||||
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||
'VALUES (:uuid, :workspace_uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
||||
).bindparams(
|
||||
uuid=kb_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
name=name,
|
||||
description=description,
|
||||
emoji=emoji,
|
||||
@@ -207,6 +264,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
)
|
||||
)
|
||||
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
try:
|
||||
config = {'embedding_model_uuid': embedding_model_uuid}
|
||||
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
|
||||
@@ -268,12 +326,13 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'INSERT INTO knowledge_bases '
|
||||
'(uuid, name, description, emoji, created_at, updated_at, '
|
||||
'(uuid, workspace_uuid, name, description, emoji, created_at, updated_at, '
|
||||
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
||||
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||
'VALUES (:uuid, :workspace_uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
||||
).bindparams(
|
||||
uuid=kb_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
name=name,
|
||||
description=description,
|
||||
emoji=emoji,
|
||||
@@ -294,6 +353,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
warnings.append(warning)
|
||||
task_context.trace(warning)
|
||||
else:
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
try:
|
||||
await self.ap.plugin_connector.rag_on_kb_create(
|
||||
external_plugin_id, kb_uuid, creation_settings_dict
|
||||
@@ -307,16 +367,23 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
||||
|
||||
# Step 5: Clear migration flag
|
||||
await self._set_migration_flag('false')
|
||||
await self._set_migration_flag(execution_context, 'false')
|
||||
task_context.trace('RAG migration completed.', action='done')
|
||||
|
||||
if warnings:
|
||||
task_context.trace(f'Completed with {len(warnings)} warning(s).')
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
needed = await self._get_migration_flag()
|
||||
@self.route(
|
||||
'/status',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
execution_context = ExecutionContext.from_request(request_context)
|
||||
execution_context = await self._require_local_migration_context(execution_context)
|
||||
needed = await self._get_migration_flag(execution_context)
|
||||
|
||||
internal_kb_count = 0
|
||||
external_kb_count = 0
|
||||
@@ -342,9 +409,16 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
needed = await self._get_migration_flag()
|
||||
@self.route(
|
||||
'/execute',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
execution_context = ExecutionContext.from_request(request_context)
|
||||
execution_context = await self._require_local_migration_context(execution_context)
|
||||
needed = await self._get_migration_flag(execution_context)
|
||||
if not needed:
|
||||
return self.http_status(400, -1, 'RAG migration is not needed')
|
||||
|
||||
@@ -353,20 +427,34 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
|
||||
self._execute_rag_migration(
|
||||
execution_context,
|
||||
task_context=ctx,
|
||||
install_plugin=install_plugin,
|
||||
),
|
||||
kind='rag-migration',
|
||||
name='rag-migration-execute',
|
||||
label='Migrating knowledge bases to plugin architecture',
|
||||
context=ctx,
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
needed = await self._get_migration_flag()
|
||||
@self.route(
|
||||
'/dismiss',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
execution_context = ExecutionContext.from_request(request_context)
|
||||
execution_context = await self._require_local_migration_context(execution_context)
|
||||
needed = await self._get_migration_flag(execution_context)
|
||||
if not needed:
|
||||
return self.http_status(400, -1, 'RAG migration is not needed')
|
||||
|
||||
await self._set_migration_flag('false')
|
||||
await self._set_migration_flag(execution_context, 'false')
|
||||
return self.success()
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import quart
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('parsers', '/api/v1/knowledge/parsers')
|
||||
class ParsersRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def list_parsers() -> quart.Response:
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def list_parsers(request_context: RequestContext) -> quart.Response:
|
||||
"""List all available parsers from plugins.
|
||||
|
||||
Optional query parameter `mime_type` to filter parsers by supported MIME type.
|
||||
"""
|
||||
mime_type = quart.request.args.get('mime_type')
|
||||
parsers = await self.ap.knowledge_service.list_parsers(mime_type)
|
||||
parsers = await self.ap.knowledge_service.list_parsers(request_context, mime_type)
|
||||
return self.success(data={'parsers': parsers})
|
||||
|
||||
@@ -3,14 +3,23 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('logs', '/api/v1/logs')
|
||||
class LogsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
@self.route('', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
# The process log is instance-global. It is safe to expose only in
|
||||
# the OSS singleton Workspace; SaaS must use Workspace-scoped
|
||||
# observability records instead of leaking another tenant's lines.
|
||||
await self.ap.workspace_service.get_local_execution_binding(
|
||||
request_context.workspace_uuid,
|
||||
expected_generation=request_context.placement_generation,
|
||||
)
|
||||
start_page_number = int(quart.request.args.get('start_page_number', 0))
|
||||
start_offset = int(quart.request.args.get('start_offset', 0))
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -24,8 +26,8 @@ def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
|
||||
@group.group_class('monitoring', '/api/v1/monitoring')
|
||||
class MonitoringRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_overview() -> str:
|
||||
@self.route('/overview', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_overview(request_context: RequestContext) -> str:
|
||||
"""Get overview metrics"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -38,6 +40,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
metrics = await self.ap.monitoring_service.get_overview_metrics(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -46,8 +49,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=metrics)
|
||||
|
||||
@self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_token_statistics() -> str:
|
||||
@self.route('/token-statistics', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_token_statistics(request_context: RequestContext) -> str:
|
||||
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
@@ -61,6 +64,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
stats = await self.ap.monitoring_service.get_token_statistics(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -70,8 +74,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=stats)
|
||||
|
||||
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_messages() -> str:
|
||||
@self.route('/messages', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_messages(request_context: RequestContext) -> str:
|
||||
"""Get message logs"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -87,6 +91,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
messages, total = await self.ap.monitoring_service.get_messages(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
session_ids=session_ids if session_ids else None,
|
||||
@@ -105,8 +110,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_llm_calls() -> str:
|
||||
@self.route('/llm-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_llm_calls(request_context: RequestContext) -> str:
|
||||
"""Get LLM call records"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -121,6 +126,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
llm_calls, total = await self.ap.monitoring_service.get_llm_calls(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -138,8 +144,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_tool_calls() -> str:
|
||||
@self.route('/tool-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_tool_calls(request_context: RequestContext) -> str:
|
||||
"""Get tool call records"""
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
@@ -153,6 +159,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
session_ids=session_ids if session_ids else None,
|
||||
@@ -171,8 +178,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_embedding_calls() -> str:
|
||||
@self.route('/embedding-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_embedding_calls(request_context: RequestContext) -> str:
|
||||
"""Get embedding call records"""
|
||||
# Parse query parameters
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
@@ -186,6 +193,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(
|
||||
request_context,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
knowledge_base_id=knowledge_base_id if knowledge_base_id else None,
|
||||
@@ -202,8 +210,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_sessions() -> str:
|
||||
@self.route('/sessions', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_sessions(request_context: RequestContext) -> str:
|
||||
"""Get session information"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -224,6 +232,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
is_active = is_active_str.lower() == 'true'
|
||||
|
||||
sessions, total = await self.ap.monitoring_service.get_sessions(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -242,8 +251,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_errors() -> str:
|
||||
@self.route('/errors', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_errors(request_context: RequestContext) -> str:
|
||||
"""Get error logs"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -258,6 +267,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
errors, total = await self.ap.monitoring_service.get_errors(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -275,8 +285,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_all_data() -> str:
|
||||
@self.route('/data', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_all_data(request_context: RequestContext) -> str:
|
||||
"""Get all monitoring data in a single request"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -291,6 +301,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get overview metrics
|
||||
overview = await self.ap.monitoring_service.get_overview_metrics(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -299,6 +310,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get messages
|
||||
messages, messages_total = await self.ap.monitoring_service.get_messages(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -309,6 +321,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get LLM calls
|
||||
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -319,6 +332,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get tool calls
|
||||
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -329,6 +343,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get sessions
|
||||
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -340,6 +355,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get errors
|
||||
errors, errors_total = await self.ap.monitoring_service.get_errors(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -350,6 +366,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get embedding calls
|
||||
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
|
||||
request_context,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
@@ -376,27 +393,27 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_session_analysis(session_id: str) -> str:
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.AUDIT_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(session_id)
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
||||
|
||||
# Always return success with the analysis data
|
||||
# The frontend will handle the 'found: false' case
|
||||
return self.success(data=analysis)
|
||||
|
||||
@self.route('/messages/<message_id>/details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_message_details(message_id: str) -> str:
|
||||
@self.route('/messages/<message_id>/details', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_message_details(message_id: str, request_context: RequestContext) -> str:
|
||||
"""Get detailed information for a specific message"""
|
||||
details = await self.ap.monitoring_service.get_message_details(message_id)
|
||||
details = await self.ap.monitoring_service.get_message_details(request_context, message_id)
|
||||
|
||||
if not details.get('found'):
|
||||
return self.error(message=f'Message {message_id} not found', code=404)
|
||||
|
||||
return self.success(data=details)
|
||||
|
||||
@self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def export_data() -> tuple[str, int]:
|
||||
@self.route('/export', methods=['GET'], permission=Permission.DATA_EXPORT)
|
||||
async def export_data(request_context: RequestContext) -> tuple[str, int]:
|
||||
"""Export monitoring data as CSV"""
|
||||
# Parse query parameters
|
||||
export_type = quart.request.args.get('type', 'messages')
|
||||
@@ -413,6 +430,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get data based on export type
|
||||
if export_type == 'messages':
|
||||
data = await self.ap.monitoring_service.export_messages(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -437,6 +455,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
]
|
||||
elif export_type == 'llm-calls':
|
||||
data = await self.ap.monitoring_service.export_llm_calls(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -463,6 +482,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
]
|
||||
elif export_type == 'embedding-calls':
|
||||
data = await self.ap.monitoring_service.export_embedding_calls(
|
||||
request_context,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
@@ -485,6 +505,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
]
|
||||
elif export_type == 'errors':
|
||||
data = await self.ap.monitoring_service.export_errors(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -506,6 +527,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
]
|
||||
elif export_type == 'sessions':
|
||||
data = await self.ap.monitoring_service.export_sessions(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -527,6 +549,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
]
|
||||
elif export_type == 'feedback':
|
||||
data = await self.ap.monitoring_service.export_feedback(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -581,8 +604,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return response, 200
|
||||
|
||||
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_feedback_stats() -> str:
|
||||
@self.route('/feedback/stats', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_feedback_stats(request_context: RequestContext) -> str:
|
||||
"""Get feedback statistics"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -595,6 +618,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
stats = await self.ap.monitoring_service.get_feedback_stats(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -603,8 +627,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=stats)
|
||||
|
||||
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_feedback() -> str:
|
||||
@self.route('/feedback', methods=['GET'], permission=Permission.AUDIT_VIEW)
|
||||
async def get_feedback(request_context: RequestContext) -> str:
|
||||
"""Get feedback list"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
@@ -623,6 +647,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
feedback_type = int(feedback_type_str) if feedback_type_str else None
|
||||
|
||||
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
feedback_type=feedback_type,
|
||||
|
||||
@@ -21,9 +21,10 @@ import quart
|
||||
|
||||
from ... import group
|
||||
from ......utils import paths
|
||||
from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
|
||||
from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_AUTH_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Cache the widget template content
|
||||
_widget_template_cache: str | None = None
|
||||
@@ -58,37 +59,31 @@ def _get_logo_bytes() -> bytes:
|
||||
class EmbedRouterGroup(group.RouterGroup):
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _resolve_bot(self, bot_uuid: str):
|
||||
async def _resolve_bot(self, bot_uuid: str):
|
||||
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||
|
||||
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||
``web_page_bot``, is disabled, or has no pipeline bound.
|
||||
"""
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if (
|
||||
bot.bot_entity.uuid == bot_uuid
|
||||
and bot.bot_entity.adapter == 'web_page_bot'
|
||||
and bot.bot_entity.enable
|
||||
and bot.bot_entity.use_pipeline_uuid
|
||||
):
|
||||
return bot, bot.bot_entity.use_pipeline_uuid
|
||||
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
|
||||
if (
|
||||
bot is not None
|
||||
and bot.bot_entity.adapter == 'web_page_bot'
|
||||
and bot.bot_entity.enable
|
||||
and bot.bot_entity.use_pipeline_uuid
|
||||
):
|
||||
return bot, bot.bot_entity.use_pipeline_uuid
|
||||
return None, None
|
||||
|
||||
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
||||
return bot.bot_entity.adapter_config
|
||||
return {}
|
||||
@staticmethod
|
||||
def _get_bot_config(runtime_bot) -> dict:
|
||||
return runtime_bot.bot_entity.adapter_config
|
||||
|
||||
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
||||
config = self._get_bot_config(bot_uuid)
|
||||
def _verify_session_token_value(self, token: str, runtime_bot) -> bool:
|
||||
config = self._get_bot_config(runtime_bot)
|
||||
secret = config.get('turnstile_secret_key', '')
|
||||
if not secret:
|
||||
return True
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return False
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
ts_str, mac = token.split('.', 1)
|
||||
ts = float(ts_str)
|
||||
@@ -99,6 +94,50 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _verify_session_token(self, request, runtime_bot) -> bool:
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
token = auth_header[7:] if auth_header.startswith('Bearer ') else ''
|
||||
return self._verify_session_token_value(token, runtime_bot)
|
||||
|
||||
async def _authenticate_websocket(self, runtime_bot) -> None:
|
||||
"""Require the embed session token as the first WebSocket frame."""
|
||||
|
||||
raw_message = await asyncio.wait_for(quart.websocket.receive(), timeout=_AUTH_TIMEOUT_SECONDS)
|
||||
payload = json.loads(raw_message)
|
||||
if not isinstance(payload, dict) or payload.get('type') != 'authenticate':
|
||||
raise ValueError('Authentication is required')
|
||||
token = str(payload.get('token') or '')
|
||||
if not self._verify_session_token_value(token, runtime_bot):
|
||||
raise ValueError('Authentication is required')
|
||||
|
||||
async def _assert_execution_active(self, runtime_bot) -> None:
|
||||
context = runtime_bot.execution_context
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
context.workspace_uuid,
|
||||
expected_generation=context.placement_generation,
|
||||
)
|
||||
|
||||
async def _resolve_connected_bot(self, owner_bot, pipeline_uuid: str):
|
||||
"""Re-resolve mutable bot state before every public message."""
|
||||
current_bot, current_pipeline_uuid = await self._resolve_bot(owner_bot.bot_entity.uuid)
|
||||
if current_bot is None or current_pipeline_uuid != pipeline_uuid:
|
||||
raise RuntimeError('Bot is unavailable')
|
||||
|
||||
owner_context = owner_bot.execution_context
|
||||
current_context = current_bot.execution_context
|
||||
if (
|
||||
current_context.instance_uuid,
|
||||
current_context.workspace_uuid,
|
||||
current_context.placement_generation,
|
||||
) != (
|
||||
owner_context.instance_uuid,
|
||||
owner_context.workspace_uuid,
|
||||
owner_context.placement_generation,
|
||||
):
|
||||
raise RuntimeError('Bot is unavailable')
|
||||
await self._assert_execution_active(current_bot)
|
||||
return current_bot
|
||||
|
||||
# -- routes --------------------------------------------------------------
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@@ -106,7 +145,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
async def verify_turnstile(bot_uuid: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
try:
|
||||
@@ -115,7 +154,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
if not token:
|
||||
return self.http_status(400, -1, 'Token is required')
|
||||
|
||||
config = self._get_bot_config(bot_uuid)
|
||||
config = self._get_bot_config(runtime_bot)
|
||||
secret = config.get('turnstile_secret_key', '')
|
||||
if not secret:
|
||||
ts = time.time()
|
||||
@@ -146,7 +185,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
"""Serve the embed widget JavaScript with injected configuration."""
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return quart.Response(
|
||||
'// Bot not found or not available', status=404, content_type='application/javascript'
|
||||
@@ -164,7 +203,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||
base_url = quart.request.host_url.rstrip('/')
|
||||
|
||||
config = self._get_bot_config(bot_uuid)
|
||||
config = self._get_bot_config(runtime_bot)
|
||||
site_key = config.get('turnstile_site_key', '')
|
||||
locale = config.get('language', 'en_US') or 'en_US'
|
||||
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
||||
@@ -194,10 +233,10 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||
if not await self._verify_session_token(quart.request, runtime_bot):
|
||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
@@ -207,7 +246,8 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
if not is_valid_session_id(session_id):
|
||||
return self.http_status(400, -1, 'Valid session_id is required')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
||||
websocket_adapter = proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
@@ -222,10 +262,10 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||
if not await self._verify_session_token(quart.request, runtime_bot):
|
||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
@@ -235,7 +275,8 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
if not is_valid_session_id(session_id):
|
||||
return self.http_status(400, -1, 'Valid session_id is required')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
||||
websocket_adapter = proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
@@ -250,10 +291,10 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
async def submit_feedback(bot_uuid: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||
if not await self._verify_session_token(quart.request, runtime_bot):
|
||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
@@ -266,6 +307,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
||||
|
||||
await self.ap.monitoring_service.record_feedback(
|
||||
runtime_bot.execution_context,
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
bot_id=runtime_bot.bot_entity.uuid,
|
||||
@@ -286,11 +328,12 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||
async def embed_websocket_connect(bot_uuid: str):
|
||||
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||
await quart.websocket.accept()
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||
return
|
||||
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||
return
|
||||
@@ -307,14 +350,23 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
|
||||
return
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
try:
|
||||
await self._authenticate_websocket(runtime_bot)
|
||||
await self._assert_execution_active(runtime_bot)
|
||||
except Exception:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||
return
|
||||
|
||||
try:
|
||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
||||
websocket_adapter = proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
return
|
||||
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
websocket=quart.websocket._get_current_object(),
|
||||
scope=WebSocketScope.from_context(runtime_bot.execution_context),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
@@ -338,7 +390,9 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
)
|
||||
|
||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
||||
receive_task = asyncio.create_task(
|
||||
self._handle_receive(connection, websocket_adapter, runtime_bot, pipeline_uuid)
|
||||
)
|
||||
send_task = asyncio.create_task(self._handle_send(connection))
|
||||
|
||||
try:
|
||||
@@ -357,7 +411,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
|
||||
# -- WebSocket receive/send helpers --------------------------------------
|
||||
|
||||
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
||||
async def _handle_receive(self, connection, websocket_adapter, owner_bot, pipeline_uuid: str):
|
||||
try:
|
||||
while connection.is_active:
|
||||
message = await quart.websocket.receive()
|
||||
@@ -372,7 +426,12 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
elif message_type == 'message':
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||
try:
|
||||
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
||||
elif message_type == 'disconnect':
|
||||
break
|
||||
|
||||
@@ -386,7 +445,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
|
||||
async def _handle_send(self, connection):
|
||||
try:
|
||||
while connection.is_active:
|
||||
while connection.is_active or not connection.send_queue.empty():
|
||||
try:
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
await quart.websocket.send(json.dumps(message))
|
||||
|
||||
@@ -2,120 +2,156 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ....service.secrets import redact_secrets
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('pipelines', '/api/v1/pipelines')
|
||||
class PipelinesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
sort_by = quart.request.args.get('sort_by', 'created_at')
|
||||
sort_order = quart.request.args.get('sort_order', 'DESC')
|
||||
return self.success(
|
||||
data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)}
|
||||
)
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data)
|
||||
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
sort_by = quart.request.args.get('sort_by', 'created_at')
|
||||
sort_order = quart.request.args.get('sort_order', 'DESC')
|
||||
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
|
||||
return self.success(
|
||||
data={
|
||||
'pipelines': await self.ap.pipeline_service.get_pipelines(
|
||||
request_context,
|
||||
sort_by,
|
||||
sort_order,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
@self.route(
|
||||
'/_/metadata',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata(request_context)})
|
||||
|
||||
return self.success(data={'pipeline': pipeline})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
@self.route(
|
||||
'/<pipeline_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(
|
||||
request_context,
|
||||
pipeline_uuid,
|
||||
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
|
||||
)
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
return self.success(data={'pipeline': pipeline})
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data)
|
||||
@self.route(
|
||||
'/<pipeline_uuid>',
|
||||
methods=['PUT', 'DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
if quart.request.method == 'PUT':
|
||||
try:
|
||||
await self.ap.pipeline_service.update_pipeline(
|
||||
request_context,
|
||||
pipeline_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
else:
|
||||
await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid)
|
||||
return self.success()
|
||||
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
@self.route(
|
||||
'/<pipeline_uuid>/copy',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
|
||||
new_uuid = await self.ap.pipeline_service.copy_pipeline(request_context, pipeline_uuid)
|
||||
return self.success(data={'uuid': new_uuid})
|
||||
except ValueError as e:
|
||||
return self.http_status(404, -1, str(e))
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@self.route(
|
||||
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'/<pipeline_uuid>/extensions',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
# Get current extensions and available plugins
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
|
||||
# Only include plugins with pipeline-related components (Command, EventListener, Tool)
|
||||
# Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions
|
||||
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
|
||||
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
|
||||
if self.ap.plugin_connector.is_enable_plugin:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
|
||||
available_skills = await self.ap.skill_service.list_skills(request_context)
|
||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||
return self.success(
|
||||
data={
|
||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
|
||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||
'available_plugins': redact_secrets(plugins),
|
||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||
'available_mcp_servers': mcp_servers,
|
||||
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
|
||||
'mcp_resource_agent_read_enabled': extensions_prefs.get('mcp_resource_agent_read_enabled', True),
|
||||
'bound_skills': extensions_prefs.get('skills', []),
|
||||
'available_skills': available_skills,
|
||||
}
|
||||
)
|
||||
|
||||
# Get available skills
|
||||
available_skills = await self.ap.skill_service.list_skills()
|
||||
|
||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||
return self.success(
|
||||
data={
|
||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||
'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
|
||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||
'available_plugins': plugins,
|
||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||
'available_mcp_servers': mcp_servers,
|
||||
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
|
||||
'mcp_resource_agent_read_enabled': extensions_prefs.get(
|
||||
'mcp_resource_agent_read_enabled', True
|
||||
),
|
||||
'bound_skills': extensions_prefs.get('skills', []),
|
||||
'available_skills': available_skills,
|
||||
}
|
||||
)
|
||||
elif quart.request.method == 'PUT':
|
||||
# Update bound plugins and MCP servers for this pipeline
|
||||
json_data = await quart.request.json
|
||||
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
||||
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
||||
enable_all_skills = json_data.get('enable_all_skills', True)
|
||||
bound_plugins = json_data.get('bound_plugins', [])
|
||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||
bound_skills = json_data.get('bound_skills', [])
|
||||
bound_mcp_resources = json_data.get('bound_mcp_resources')
|
||||
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
pipeline_uuid,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
enable_all_plugins,
|
||||
enable_all_mcp_servers,
|
||||
bound_skills=bound_skills,
|
||||
enable_all_skills=enable_all_skills,
|
||||
bound_mcp_resources=bound_mcp_resources,
|
||||
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
|
||||
)
|
||||
|
||||
return self.success()
|
||||
@self.route(
|
||||
'/<pipeline_uuid>/extensions',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
request_context,
|
||||
pipeline_uuid,
|
||||
json_data.get('bound_plugins', []),
|
||||
json_data.get('bound_mcp_servers', []),
|
||||
json_data.get('enable_all_plugins', True),
|
||||
json_data.get('enable_all_mcp_servers', True),
|
||||
bound_skills=json_data.get('bound_skills', []),
|
||||
enable_all_skills=json_data.get('enable_all_skills', True),
|
||||
bound_mcp_resources=json_data.get('bound_mcp_resources'),
|
||||
mcp_resource_agent_read_enabled=json_data.get('mcp_resource_agent_read_enabled'),
|
||||
)
|
||||
return self.success()
|
||||
|
||||
@@ -1,64 +1,157 @@
|
||||
"""WebSocket聊天路由 - 支持双向实时通信"""
|
||||
"""Authenticated dashboard WebSocket chat routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import quart
|
||||
|
||||
from ....authz import Permission, permissions_for_role, require_permission
|
||||
from ....context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from ... import group
|
||||
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||
from ......platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_AUTH_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@group.group_class('websocket_chat', '/api/v1/pipelines/<pipeline_uuid>/ws')
|
||||
class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
async def _authenticate_websocket(self) -> tuple[RequestContext, str]:
|
||||
"""Authenticate the first dashboard WebSocket message.
|
||||
|
||||
Browsers cannot attach the normal Authorization/X-Workspace-Id headers
|
||||
to a WebSocket handshake. The client therefore sends one auth frame
|
||||
immediately after opening the socket; no connection is registered and
|
||||
no runtime object is resolved before this method succeeds.
|
||||
"""
|
||||
|
||||
raw_message = await asyncio.wait_for(quart.websocket.receive(), timeout=_AUTH_TIMEOUT_SECONDS)
|
||||
payload = json.loads(raw_message)
|
||||
if not isinstance(payload, dict) or payload.get('type') != 'authenticate':
|
||||
raise ValueError('Authentication is required')
|
||||
|
||||
token = str(payload.get('token') or '').strip()
|
||||
workspace_uuid = str(payload.get('workspace_uuid') or '').strip()
|
||||
if not token or not workspace_uuid:
|
||||
raise ValueError('Authentication is required')
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
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:
|
||||
raise ValueError('Workspace authentication is unavailable')
|
||||
|
||||
access = await collaboration_service.resolve_account_workspace(account_uuid, workspace_uuid)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=access.execution.instance_uuid,
|
||||
placement_generation=access.execution.placement_generation,
|
||||
request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()),
|
||||
auth_type=group.AuthType.USER_TOKEN.value,
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.ACCOUNT,
|
||||
account_uuid=account_uuid,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=access.workspace.uuid,
|
||||
membership_uuid=access.membership.uuid,
|
||||
role=access.membership.role,
|
||||
permissions=permissions_for_role(access.membership.role),
|
||||
membership_revision=access.membership.projection_revision,
|
||||
),
|
||||
)
|
||||
require_permission(request_context, Permission.RUNTIME_OPERATE)
|
||||
return request_context, token
|
||||
|
||||
async def _revalidate_websocket_authorization(
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
) -> None:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
if account_uuid != request_context.account_uuid:
|
||||
raise ValueError('WebSocket account changed')
|
||||
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
if collaboration_service is None or not isinstance(account_uuid, str):
|
||||
raise ValueError('Workspace authentication is unavailable')
|
||||
access = await collaboration_service.resolve_account_workspace(
|
||||
account_uuid,
|
||||
request_context.workspace_uuid,
|
||||
)
|
||||
if (
|
||||
access.workspace.uuid != request_context.workspace_uuid
|
||||
or access.membership.uuid != request_context.workspace.membership_uuid
|
||||
or access.membership.projection_revision != request_context.workspace.membership_revision
|
||||
or access.execution.instance_uuid != request_context.instance_uuid
|
||||
or access.execution.placement_generation != request_context.placement_generation
|
||||
):
|
||||
raise ValueError('WebSocket authorization changed')
|
||||
|
||||
current_context = RequestContext(
|
||||
instance_uuid=access.execution.instance_uuid,
|
||||
placement_generation=access.execution.placement_generation,
|
||||
request_id=request_context.request_id,
|
||||
auth_type=request_context.auth_type,
|
||||
principal=request_context.principal,
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=access.workspace.uuid,
|
||||
membership_uuid=access.membership.uuid,
|
||||
role=access.membership.role,
|
||||
permissions=permissions_for_role(access.membership.role),
|
||||
membership_revision=access.membership.projection_revision,
|
||||
),
|
||||
entitlement_revision=request_context.entitlement_revision,
|
||||
)
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
|
||||
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return None
|
||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(request_context)
|
||||
return proxy_bot.adapter
|
||||
|
||||
async def initialize(self) -> None:
|
||||
# 直接使用 quart_app 注册 WebSocket 路由
|
||||
@self.quart_app.websocket(self.path + '/connect')
|
||||
async def websocket_connect(pipeline_uuid: str):
|
||||
"""
|
||||
建立WebSocket连接
|
||||
"""Open one authenticated dashboard debug connection."""
|
||||
|
||||
URL参数:
|
||||
- pipeline_uuid: 流水线UUID
|
||||
- session_type: 会话类型 (person/group)
|
||||
"""
|
||||
await quart.websocket.accept()
|
||||
try:
|
||||
# 获取参数 - 在WebSocket上下文中使用 quart.websocket.args
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
request_context, token = await self._authenticate_websocket()
|
||||
except Exception:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||
return
|
||||
|
||||
if session_type not in ['person', 'group']:
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||
)
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
if session_type not in ['person', 'group']:
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
||||
if websocket_adapter is None:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
|
||||
return
|
||||
|
||||
# 获取WebSocket适配器
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
return
|
||||
|
||||
# Dashboard pipeline-debug sessions must always run under the
|
||||
# built-in websocket_proxy_bot identity. We deliberately do NOT
|
||||
# resolve a web_page_bot owner here — even if one is bound to
|
||||
# the same pipeline, debug requests must not be attributed to
|
||||
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
|
||||
# is the one that carries the page-bot identity.
|
||||
|
||||
# 注册连接
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
websocket=quart.websocket._get_current_object(),
|
||||
scope=WebSocketScope.from_context(request_context),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||
)
|
||||
|
||||
# 发送连接成功消息
|
||||
await quart.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -72,182 +165,180 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'WebSocket connection established: {connection.connection_id} '
|
||||
f'(pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
f'Dashboard WebSocket connected: {connection.connection_id} '
|
||||
f'(workspace={connection.workspace_uuid}, pipeline={pipeline_uuid}, '
|
||||
f'session_type={session_type})'
|
||||
)
|
||||
|
||||
# 创建接收和发送任务
|
||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
||||
receive_task = asyncio.create_task(
|
||||
self._handle_receive(
|
||||
connection,
|
||||
websocket_adapter,
|
||||
request_context,
|
||||
token,
|
||||
)
|
||||
)
|
||||
send_task = asyncio.create_task(self._handle_send(connection))
|
||||
|
||||
# 等待任务完成
|
||||
try:
|
||||
await asyncio.gather(receive_task, send_task)
|
||||
except Exception as e:
|
||||
logger.error(f'WebSocket task execution error: {e}')
|
||||
except Exception as exc:
|
||||
logger.error(f'WebSocket task execution error: {exc}')
|
||||
finally:
|
||||
# 清理连接
|
||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||
logger.debug(f'WebSocket connection cleaned: {connection.connection_id}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'WebSocket connection error: {e}', exc_info=True)
|
||||
except Exception:
|
||||
logger.error('Dashboard WebSocket connection error', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
|
||||
except:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@self.route('/messages/<session_type>', methods=['GET'])
|
||||
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
|
||||
"""获取消息历史"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
@self.route(
|
||||
'/messages/<session_type>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def get_messages(
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
request_context: RequestContext,
|
||||
) -> str:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
||||
if websocket_adapter is None:
|
||||
return self.http_status(404, -1, 'Pipeline not found')
|
||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||
return self.success(data={'messages': messages})
|
||||
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
@self.route(
|
||||
'/reset/<session_type>',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def reset_session(
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
request_context: RequestContext,
|
||||
) -> str:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
||||
if websocket_adapter is None:
|
||||
return self.http_status(404, -1, 'Pipeline not found')
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
return self.success(data={'messages': messages})
|
||||
@self.route(
|
||||
'/connections',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def get_connections(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
if await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) is None:
|
||||
return self.http_status(404, -1, 'Pipeline not found')
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/reset/<session_type>', methods=['POST'])
|
||||
async def reset_session(pipeline_uuid: str, session_type: str) -> str:
|
||||
"""重置会话"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/connections', methods=['GET'])
|
||||
async def get_connections(pipeline_uuid: str) -> str:
|
||||
"""获取当前连接统计"""
|
||||
try:
|
||||
stats = ws_connection_manager.get_stats()
|
||||
connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'stats': stats,
|
||||
'connections': [
|
||||
{
|
||||
'connection_id': conn.connection_id,
|
||||
'session_type': conn.session_type,
|
||||
'created_at': conn.created_at.isoformat(),
|
||||
'last_active': conn.last_active.isoformat(),
|
||||
'is_active': conn.is_active,
|
||||
}
|
||||
for conn in connections
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/broadcast', methods=['POST'])
|
||||
async def broadcast_message(pipeline_uuid: str) -> str:
|
||||
"""向所有连接广播消息(后端主动推送)"""
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
message = data.get('message')
|
||||
|
||||
if not message:
|
||||
return self.http_status(400, -1, 'message is required')
|
||||
|
||||
# 广播消息
|
||||
broadcast_data = {
|
||||
'type': 'broadcast',
|
||||
'message': message,
|
||||
'timestamp': datetime.datetime.now().isoformat(),
|
||||
scope = WebSocketScope.from_context(request_context)
|
||||
stats = ws_connection_manager.get_stats(scope=scope)
|
||||
connections = await ws_connection_manager.get_connections_by_pipeline(
|
||||
pipeline_uuid,
|
||||
scope=scope,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'stats': stats,
|
||||
'connections': [
|
||||
{
|
||||
'connection_id': connection.connection_id,
|
||||
'session_type': connection.session_type,
|
||||
'created_at': connection.created_at.isoformat(),
|
||||
'last_active': connection.last_active.isoformat(),
|
||||
'is_active': connection.is_active,
|
||||
}
|
||||
for connection in connections
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data)
|
||||
@self.route(
|
||||
'/broadcast',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def broadcast_message(pipeline_uuid: str, request_context: RequestContext) -> str:
|
||||
if await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) is None:
|
||||
return self.http_status(404, -1, 'Pipeline not found')
|
||||
|
||||
return self.success(data={'message': 'Broadcast sent successfully'})
|
||||
data = await quart.request.get_json()
|
||||
message = data.get('message')
|
||||
if not message:
|
||||
return self.http_status(400, -1, 'message is required')
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
broadcast_data = {
|
||||
'type': 'broadcast',
|
||||
'message': message,
|
||||
'timestamp': datetime.datetime.now().isoformat(),
|
||||
}
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
broadcast_data,
|
||||
scope=WebSocketScope.from_context(request_context),
|
||||
)
|
||||
return self.success(data={'message': 'Broadcast sent successfully'})
|
||||
|
||||
async def _handle_receive(self, connection, websocket_adapter):
|
||||
"""处理接收消息的任务"""
|
||||
async def _handle_receive(
|
||||
self,
|
||||
connection,
|
||||
websocket_adapter,
|
||||
request_context: RequestContext,
|
||||
token: str,
|
||||
):
|
||||
try:
|
||||
while connection.is_active:
|
||||
# 接收消息
|
||||
message = await quart.websocket.receive()
|
||||
|
||||
# 更新活跃时间
|
||||
await ws_connection_manager.update_activity(connection.connection_id)
|
||||
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get('type', 'message')
|
||||
|
||||
if message_type == 'ping':
|
||||
# 心跳响应
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
|
||||
elif message_type == 'message':
|
||||
# 处理用户消息
|
||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||
|
||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||
# owner_bot is intentionally NOT passed: the dashboard
|
||||
# debug WebSocket must always run under the proxy bot,
|
||||
# never under a coincidentally-bound web_page_bot.
|
||||
try:
|
||||
await self._revalidate_websocket_authorization(request_context, token)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
|
||||
elif message_type == 'disconnect':
|
||||
# 客户端主动断开
|
||||
logger.debug(f'Client disconnected: {connection.connection_id}')
|
||||
break
|
||||
|
||||
else:
|
||||
logger.warning(f'Unknown message type: {message_type}')
|
||||
|
||||
logger.warning(f'Unknown WebSocket message type: {message_type}')
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f'Invalid JSON message: {message}')
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Receive message error: {e}', exc_info=True)
|
||||
except Exception:
|
||||
logger.error('Dashboard WebSocket receive error', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
|
||||
async def _handle_send(self, connection):
|
||||
"""处理发送消息的任务"""
|
||||
try:
|
||||
while connection.is_active:
|
||||
# 从队列获取消息
|
||||
while connection.is_active or not connection.send_queue.empty():
|
||||
try:
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
|
||||
# 发送消息
|
||||
await quart.websocket.send(json.dumps(message))
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时继续循环
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Send message error: {e}', exc_info=True)
|
||||
except Exception:
|
||||
logger.error('Dashboard WebSocket send error', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
|
||||
@@ -1,9 +1,75 @@
|
||||
import quart
|
||||
import mimetypes
|
||||
import asyncio
|
||||
from ... import group
|
||||
import dataclasses
|
||||
import mimetypes
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.context import RequestContext
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
from ... import group
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class _AdapterSessionScope:
|
||||
"""Immutable tenant and principal binding for a credential exchange."""
|
||||
|
||||
instance_uuid: str
|
||||
workspace_uuid: str
|
||||
placement_generation: int
|
||||
principal_type: str
|
||||
account_uuid: str | None
|
||||
api_key_uuid: str | None
|
||||
|
||||
@classmethod
|
||||
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
|
||||
principal = request_context.principal
|
||||
return cls(
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
principal_type=principal.principal_type.value,
|
||||
account_uuid=principal.account_uuid,
|
||||
api_key_uuid=principal.api_key_uuid,
|
||||
)
|
||||
|
||||
def matches(self, request_context: RequestContext) -> bool:
|
||||
"""Return whether a request is from the exact initiating tenant principal."""
|
||||
|
||||
return self == self.from_request_context(request_context)
|
||||
|
||||
|
||||
def _bind_session_scope(session: dict, request_context: RequestContext) -> None:
|
||||
session['scope'] = _AdapterSessionScope.from_request_context(request_context)
|
||||
|
||||
|
||||
def _get_owned_session(
|
||||
sessions: dict[str, dict],
|
||||
session_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> dict | None:
|
||||
"""Resolve a session without revealing sessions owned by another scope."""
|
||||
|
||||
session = sessions.get(session_id)
|
||||
scope = session.get('scope') if session is not None else None
|
||||
if not isinstance(scope, _AdapterSessionScope) or not scope.matches(request_context):
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
def _pop_owned_session(
|
||||
sessions: dict[str, dict],
|
||||
session_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> dict | None:
|
||||
"""Remove an owned session without allowing cross-scope cancellation."""
|
||||
|
||||
session = _get_owned_session(sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return None
|
||||
return sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
|
||||
"""Decrypt the AppSecret returned by the QQ Official QR binding endpoint.
|
||||
@@ -84,8 +150,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/lark/create-app', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/lark/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -106,6 +172,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'error': None,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_create_app_sessions[session_id] = session
|
||||
|
||||
def on_qr_code(info):
|
||||
@@ -160,10 +227,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/lark/create-app/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll registration status."""
|
||||
session = _create_app_sessions.get(session_id)
|
||||
_cleanup_expired_sessions()
|
||||
session = _get_owned_session(_create_app_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -179,10 +251,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/lark/create-app/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a registration session."""
|
||||
session = _create_app_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_create_app_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -206,8 +284,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/weixin/login', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/weixin/login', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -229,6 +307,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'error': None,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_weixin_login_sessions[session_id] = session
|
||||
|
||||
client = OpenClawWeixinClient(
|
||||
@@ -290,10 +369,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/weixin/login/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll WeChat login status."""
|
||||
session = _weixin_login_sessions.get(session_id)
|
||||
_cleanup_expired_weixin_sessions()
|
||||
session = _get_owned_session(_weixin_login_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -317,10 +401,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/weixin/login/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a WeChat login session."""
|
||||
session = _weixin_login_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_weixin_login_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -344,8 +434,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/dingtalk/create-app', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/dingtalk/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -368,6 +458,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'device_code': None,
|
||||
'interval': 5,
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_dingtalk_sessions[session_id] = session
|
||||
|
||||
async def run_device_flow():
|
||||
@@ -491,11 +582,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/dingtalk/create-app/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll DingTalk Device Flow status."""
|
||||
_cleanup_expired_dingtalk_sessions()
|
||||
session = _dingtalk_sessions.get(session_id)
|
||||
session = _get_owned_session(_dingtalk_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -511,10 +606,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/dingtalk/create-app/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a DingTalk Device Flow session."""
|
||||
session = _dingtalk_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_dingtalk_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -538,8 +639,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/wecombot/create-bot', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/wecombot/create-bot', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -563,6 +664,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'scode': None,
|
||||
'task': None,
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_wecombot_sessions[session_id] = session
|
||||
|
||||
async def run_qr_flow():
|
||||
@@ -655,11 +757,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/wecombot/create-bot/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll WeComBot creation status."""
|
||||
_cleanup_expired_wecombot_sessions()
|
||||
session = _wecombot_sessions.get(session_id)
|
||||
session = _get_owned_session(_wecombot_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -675,10 +781,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/wecombot/create-bot/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a WeComBot creation session."""
|
||||
session = _wecombot_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_wecombot_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -702,8 +814,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/qqofficial/bind', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/qqofficial/bind', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start QQ Official QR binding. Returns session_id + QR URL.
|
||||
|
||||
Flow: generate a local AES-256 key, register it with
|
||||
@@ -739,6 +851,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'bind_key_bytes': bind_key_bytes,
|
||||
'interval': 2,
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_qqofficial_sessions[session_id] = session
|
||||
|
||||
async def run_qr_binding():
|
||||
@@ -870,11 +983,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/qqofficial/bind/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll QQ Official QR binding status."""
|
||||
_cleanup_expired_qqofficial_sessions()
|
||||
session = _qqofficial_sessions.get(session_id)
|
||||
session = _get_owned_session(_qqofficial_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -892,10 +1009,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/qqofficial/bind/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a QQ Official QR binding session."""
|
||||
session = _qqofficial_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_qqofficial_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
|
||||
@@ -1,45 +1,95 @@
|
||||
import quart
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('bots', '/api/v1/platform/bots')
|
||||
class BotsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
bot_uuid = await self.ap.bot_service.create_bot(json_data)
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
|
||||
return self.success(
|
||||
data={
|
||||
'bots': await self.ap.bot_service.get_bots(
|
||||
request_context,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
||||
if bot is None:
|
||||
return self.http_status(404, -1, 'bot not found')
|
||||
return self.success(data={'bot': bot})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.bot_service.update_bot(bot_uuid, json_data)
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.bot_service.delete_bot(bot_uuid)
|
||||
return self.success()
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
|
||||
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
@self.route(
|
||||
'/<bot_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
|
||||
bot = await self.ap.bot_service.get_runtime_bot_info(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if bot is None:
|
||||
return self.http_status(404, -1, 'bot not found')
|
||||
return self.success(data={'bot': bot})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>',
|
||||
methods=['PUT', 'DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
|
||||
else:
|
||||
await self.ap.bot_service.delete_bot(request_context, bot_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/logs',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
from_index = json_data.get('from_index', -1)
|
||||
max_count = json_data.get('max_count', 10)
|
||||
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
||||
logs, total_count = await self.ap.bot_service.list_event_logs(
|
||||
request_context, bot_uuid, from_index, max_count
|
||||
)
|
||||
return self.success(data={'logs': logs, 'total_count': total_count})
|
||||
|
||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
@self.route(
|
||||
'/<bot_uuid>/send_message',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.API_KEY,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
target_type = json_data.get('target_type')
|
||||
target_id = json_data.get('target_id')
|
||||
@@ -54,37 +104,51 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
if target_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
||||
|
||||
try:
|
||||
await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data)
|
||||
return self.success(data={'sent': True})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||
|
||||
# ============ Bot Admins ============
|
||||
|
||||
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
|
||||
return self.success(data={'admins': admins})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
launcher_type = json_data.get('launcher_type', '').strip()
|
||||
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
||||
if not launcher_type or not launcher_id:
|
||||
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
||||
try:
|
||||
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
|
||||
return self.success(data={'id': admin_id})
|
||||
except Exception as e:
|
||||
return self.http_status(409, -1, str(e))
|
||||
await self.ap.bot_service.send_message(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
target_type,
|
||||
target_id,
|
||||
message_chain_data,
|
||||
)
|
||||
return self.success(data={'sent': True})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, admin_id: int) -> str:
|
||||
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
admins = await self.ap.bot_service.get_bot_admins(request_context, bot_uuid)
|
||||
return self.success(data={'admins': admins})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
launcher_type = json_data.get('launcher_type', '').strip()
|
||||
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
||||
if not launcher_type or not launcher_id:
|
||||
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
||||
try:
|
||||
admin_id = await self.ap.bot_service.add_bot_admin(
|
||||
request_context, bot_uuid, launcher_type, launcher_id
|
||||
)
|
||||
return self.success(data={'id': admin_id})
|
||||
except IntegrityError as e:
|
||||
return self.http_status(409, -1, str(e))
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins/<int:admin_id>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, admin_id: int, request_context: RequestContext) -> str:
|
||||
await self.ap.bot_service.delete_bot_admin(request_context, bot_uuid, admin_id)
|
||||
return self.success()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections.abc
|
||||
import copy
|
||||
import io
|
||||
import quart
|
||||
import re
|
||||
@@ -15,9 +17,139 @@ import sqlalchemy
|
||||
|
||||
from .....core import taskmgr
|
||||
from .....entity.persistence import plugin as persistence_plugin
|
||||
from ...authz import Permission
|
||||
from ...context import ExecutionContext, RequestContext
|
||||
from .. import group
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
|
||||
_SECRET_MASK = '***'
|
||||
_MISSING_SECRET = object()
|
||||
_SENSITIVE_CONFIG_NAMES = frozenset(
|
||||
{
|
||||
'api_key',
|
||||
'apikey',
|
||||
'auth',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'credentials',
|
||||
'database_url',
|
||||
'dsn',
|
||||
'key',
|
||||
'proxy_authorization',
|
||||
'set_cookie',
|
||||
}
|
||||
)
|
||||
_SENSITIVE_CONFIG_TOKENS = frozenset(
|
||||
{
|
||||
'credential',
|
||||
'credentials',
|
||||
'passwd',
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
}
|
||||
)
|
||||
_SENSITIVE_KEY_QUALIFIERS = frozenset(
|
||||
{
|
||||
'access',
|
||||
'api',
|
||||
'auth',
|
||||
'bearer',
|
||||
'client',
|
||||
'debug',
|
||||
'encryption',
|
||||
'private',
|
||||
'signing',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_config_key(key: object) -> str:
|
||||
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
|
||||
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
|
||||
|
||||
|
||||
def _is_sensitive_config_key(key: object) -> bool:
|
||||
normalized = _normalize_config_key(key)
|
||||
if normalized in _SENSITIVE_CONFIG_NAMES:
|
||||
return True
|
||||
tokens = frozenset(token for token in normalized.split('_') if token)
|
||||
if tokens & _SENSITIVE_CONFIG_TOKENS:
|
||||
return True
|
||||
return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
|
||||
|
||||
|
||||
def _mask_secret_structure(value):
|
||||
"""Mask every non-empty leaf while preserving container structure."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {key: _mask_secret_structure(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_mask_secret_structure(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_mask_secret_structure(item) for item in value)
|
||||
if value is None or value == '':
|
||||
return value
|
||||
return _SECRET_MASK
|
||||
|
||||
|
||||
def redact_plugin_secrets(value):
|
||||
"""Return a recursively redacted copy of plugin-facing data."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (_mask_secret_structure(item) if _is_sensitive_config_key(key) else redact_plugin_secrets(item))
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_plugin_secrets(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_plugin_secrets(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def restore_plugin_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
|
||||
"""Restore masked leaves from the current config before a management write."""
|
||||
|
||||
if sensitive and value == _SECRET_MASK:
|
||||
if current_value is _MISSING_SECRET:
|
||||
raise ValueError('Masked plugin secret has no existing value')
|
||||
return copy.deepcopy(current_value)
|
||||
if isinstance(value, dict):
|
||||
current_mapping = current_value if isinstance(current_value, dict) else {}
|
||||
return {
|
||||
key: restore_plugin_secret_placeholders(
|
||||
item,
|
||||
current_mapping.get(key, _MISSING_SECRET),
|
||||
sensitive=sensitive or _is_sensitive_config_key(key),
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return [
|
||||
restore_plugin_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return tuple(
|
||||
restore_plugin_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Resolve the built-in page SDK JS from the langbot_plugin package
|
||||
_PAGE_SDK_PATH = None
|
||||
try:
|
||||
@@ -148,18 +280,74 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
'subdir': subdir,
|
||||
}
|
||||
|
||||
async def _check_extensions_limit(self) -> str | None:
|
||||
async def _check_extensions_limit(self, request_context: RequestContext) -> str | None:
|
||||
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_extensions = limitation.get('max_extensions', -1)
|
||||
if max_extensions >= 0:
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers()
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context)
|
||||
total_extensions = len(plugins) + len(mcp_servers)
|
||||
if total_extensions >= max_extensions:
|
||||
return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _task_scope(request_context: RequestContext) -> dict[str, str | int]:
|
||||
return {
|
||||
'instance_uuid': request_context.instance_uuid,
|
||||
'workspace_uuid': request_context.workspace_uuid,
|
||||
'placement_generation': request_context.placement_generation,
|
||||
}
|
||||
|
||||
async def _run_fenced_plugin_operation(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
operation: collections.abc.Callable[[], collections.abc.Awaitable],
|
||||
):
|
||||
"""Revalidate a captured task context immediately before Runtime I/O."""
|
||||
|
||||
await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
return await operation()
|
||||
|
||||
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
|
||||
"""Resolve public assets only for the OSS singleton Workspace.
|
||||
|
||||
Public image and iframe requests cannot carry the WebUI bearer token.
|
||||
They therefore remain available for the one-Workspace Core deployment,
|
||||
but fail closed instead of guessing a Workspace when multi-Workspace
|
||||
policy is active.
|
||||
"""
|
||||
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
policy = getattr(workspace_service, 'policy', None)
|
||||
if workspace_service is None or policy is None or getattr(policy, 'multi_workspace_enabled', False):
|
||||
raise WorkspaceNotFoundError('Plugin resource not found')
|
||||
binding = await workspace_service.get_local_execution_binding()
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
return await self.ap.plugin_connector.require_workspace_context(execution_context)
|
||||
|
||||
async def _get_stored_plugin_config(
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
author: str,
|
||||
plugin_name: str,
|
||||
plugin: dict,
|
||||
):
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_plugin.PluginSetting.config)
|
||||
.where(persistence_plugin.PluginSetting.workspace_uuid == request_context.workspace_uuid)
|
||||
.where(persistence_plugin.PluginSetting.plugin_author == author)
|
||||
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||
)
|
||||
persisted_config = result.scalar_one_or_none()
|
||||
return persisted_config if persisted_config is not None else plugin['plugin_config']
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> quart.Response:
|
||||
@@ -170,15 +358,27 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return quart.Response(content, mimetype='application/javascript')
|
||||
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
|
||||
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
|
||||
return self.success(data={'plugins': plugins})
|
||||
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
||||
|
||||
@self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/debug-info',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
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()
|
||||
|
||||
# Get debug URL from config
|
||||
@@ -196,77 +396,121 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
'/<author>/<plugin_name>/upgrade',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
|
||||
self._run_fenced_plugin_operation(
|
||||
execution_context,
|
||||
lambda: self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
|
||||
),
|
||||
kind='plugin-operation',
|
||||
name=f'plugin-upgrade-{plugin_name}',
|
||||
label=f'Upgrading plugin {plugin_name}',
|
||||
context=ctx,
|
||||
**self._task_scope(request_context),
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>',
|
||||
methods=['GET', 'DELETE'],
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
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')
|
||||
return self.success(data={'plugin': plugin})
|
||||
elif quart.request.method == 'DELETE':
|
||||
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.delete_plugin(
|
||||
author, plugin_name, delete_data=delete_data, task_context=ctx
|
||||
),
|
||||
kind='plugin-operation',
|
||||
name=f'plugin-remove-{plugin_name}',
|
||||
label=f'Removing plugin {plugin_name}',
|
||||
context=ctx,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_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')
|
||||
return self.success(data={'plugin': redact_plugin_secrets(plugin)})
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._run_fenced_plugin_operation(
|
||||
execution_context,
|
||||
lambda: self.ap.plugin_connector.delete_plugin(
|
||||
author,
|
||||
plugin_name,
|
||||
delete_data=delete_data,
|
||||
task_context=ctx,
|
||||
),
|
||||
),
|
||||
kind='plugin-operation',
|
||||
name=f'plugin-remove-{plugin_name}',
|
||||
label=f'Removing plugin {plugin_name}',
|
||||
context=ctx,
|
||||
**self._task_scope(request_context),
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/config',
|
||||
methods=['GET', 'PUT'],
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_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')
|
||||
|
||||
if quart.request.method == 'GET':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_plugin.PluginSetting.config)
|
||||
.where(persistence_plugin.PluginSetting.plugin_author == author)
|
||||
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||
config = await self._get_stored_plugin_config(
|
||||
request_context,
|
||||
author,
|
||||
plugin_name,
|
||||
plugin,
|
||||
)
|
||||
return self.success(data={'config': redact_plugin_secrets(config)})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/config',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
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)
|
||||
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')
|
||||
current_config = await self._get_stored_plugin_config(
|
||||
request_context,
|
||||
author,
|
||||
plugin_name,
|
||||
plugin,
|
||||
)
|
||||
try:
|
||||
config = restore_plugin_secret_placeholders(
|
||||
await quart.request.json,
|
||||
current_config,
|
||||
)
|
||||
persisted_config = result.scalar_one_or_none()
|
||||
|
||||
config = persisted_config if persisted_config is not None else plugin['plugin_config']
|
||||
return self.success(data={'config': config})
|
||||
elif quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
|
||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data)
|
||||
|
||||
return self.success(data={})
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/readme',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_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})
|
||||
@@ -275,8 +519,10 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
'/<author>/<plugin_name>/logs',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
try:
|
||||
limit = int(quart.request.args.get('limit', 200))
|
||||
except (TypeError, ValueError):
|
||||
@@ -291,6 +537,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
auth_type=group.AuthType.NONE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
await self._require_public_plugin_runtime_context()
|
||||
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
|
||||
icon_base64 = icon_data['plugin_icon_base64']
|
||||
mime_type = icon_data['mime_type']
|
||||
@@ -305,6 +552,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
auth_type=group.AuthType.NONE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||
await self._require_public_plugin_runtime_context()
|
||||
asset_path = _normalize_plugin_asset_path(filepath)
|
||||
if asset_path is None:
|
||||
return quart.Response('Asset not found', status=404)
|
||||
@@ -334,9 +582,11 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
'/<author>/<plugin_name>/page-api',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
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)
|
||||
data = await quart.request.json
|
||||
if not isinstance(data, dict):
|
||||
return self.http_status(400, -1, 'invalid request body')
|
||||
@@ -357,9 +607,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, result['error'])
|
||||
return self.success(data=result.get('data'))
|
||||
|
||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/github/releases',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get releases from a GitHub repository URL"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
data = await quart.request.json
|
||||
repo_url = data.get('repo_url', '')
|
||||
|
||||
@@ -427,16 +683,18 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
'source_subdir': requested_subdir,
|
||||
}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
|
||||
except httpx.RequestError:
|
||||
raise
|
||||
|
||||
@self.route(
|
||||
'/github/release-assets',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _() -> str:
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get assets from a specific GitHub release"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
data = await quart.request.json
|
||||
owner = data.get('owner', '')
|
||||
repo = data.get('repo', '')
|
||||
@@ -484,13 +742,18 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
# )
|
||||
|
||||
return self.success(data={'assets': formatted_assets})
|
||||
except httpx.RequestError as e:
|
||||
return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
|
||||
except httpx.RequestError:
|
||||
raise
|
||||
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/install/github',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Install plugin from GitHub release asset"""
|
||||
limit_error = await self._check_extensions_limit()
|
||||
limit_error = await self._check_extensions_limit(request_context)
|
||||
if limit_error is not None:
|
||||
return limit_error
|
||||
|
||||
@@ -503,6 +766,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
if not asset_url:
|
||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||
ctx.metadata['install_source'] = 'github'
|
||||
@@ -515,11 +780,19 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
}
|
||||
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),
|
||||
self._run_fenced_plugin_operation(
|
||||
execution_context,
|
||||
lambda: self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.GITHUB,
|
||||
install_info,
|
||||
task_context=ctx,
|
||||
),
|
||||
),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-github',
|
||||
label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',
|
||||
context=ctx,
|
||||
**self._task_scope(request_context),
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
@@ -528,9 +801,10 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
'/install/marketplace',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _() -> str:
|
||||
limit_error = await self._check_extensions_limit()
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
limit_error = await self._check_extensions_limit(request_context)
|
||||
if limit_error is not None:
|
||||
return limit_error
|
||||
|
||||
@@ -538,23 +812,37 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
plugin_author = data.get('plugin_author', '')
|
||||
plugin_name = data.get('plugin_name', '')
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
ctx.metadata['install_source'] = 'marketplace'
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||
self._run_fenced_plugin_operation(
|
||||
execution_context,
|
||||
lambda: self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
data,
|
||||
task_context=ctx,
|
||||
),
|
||||
),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-marketplace',
|
||||
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||
context=ctx,
|
||||
**self._task_scope(request_context),
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
limit_error = await self._check_extensions_limit()
|
||||
@self.route(
|
||||
'/install/local',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
limit_error = await self._check_extensions_limit(request_context)
|
||||
if limit_error is not None:
|
||||
return limit_error
|
||||
|
||||
@@ -563,6 +851,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
|
||||
file_bytes = file.read()
|
||||
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
data = {
|
||||
'plugin_file': file_bytes,
|
||||
@@ -572,17 +861,31 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
|
||||
ctx.metadata['install_source'] = 'local'
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||
self._run_fenced_plugin_operation(
|
||||
execution_context,
|
||||
lambda: self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.LOCAL,
|
||||
data,
|
||||
task_context=ctx,
|
||||
),
|
||||
),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-local',
|
||||
label=f'Installing plugin from local {file.filename}',
|
||||
context=ctx,
|
||||
**self._task_scope(request_context),
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/install/local/preview',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -634,12 +937,18 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
except zipfile.BadZipFile:
|
||||
return self.http_status(400, -1, 'invalid .lbpkg file')
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/config-files',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Upload a file for plugin configuration"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -650,25 +959,37 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
if len(file_bytes) > MAX_FILE_SIZE:
|
||||
return self.http_status(400, -1, 'file size exceeds 10MB limit')
|
||||
|
||||
# Generate unique file key with original extension
|
||||
original_filename = file.filename
|
||||
original_filename = file.filename or 'config.bin'
|
||||
_, ext = os.path.splitext(original_filename)
|
||||
file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
|
||||
|
||||
# Save file using storage manager
|
||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||
logical_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
|
||||
file_key = await self.ap.storage_mgr.save_scoped(
|
||||
request_context,
|
||||
owner_type='plugin_config',
|
||||
owner=request_context.workspace_uuid,
|
||||
key=logical_key,
|
||||
value=file_bytes,
|
||||
)
|
||||
|
||||
return self.success(data={'file_key': file_key})
|
||||
|
||||
@self.route('/config-files/<file_key>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(file_key: str) -> str:
|
||||
@self.route(
|
||||
'/config-files/<path:file_key>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(file_key: str, request_context: RequestContext) -> str:
|
||||
"""Delete a plugin configuration file"""
|
||||
# Only allow deletion of files with plugin_config_ prefix for security
|
||||
if not file_key.startswith('plugin_config_'):
|
||||
await self.ap.plugin_connector.require_workspace_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')
|
||||
|
||||
try:
|
||||
await self.ap.storage_mgr.storage_provider.delete(file_key)
|
||||
await self.ap.storage_mgr.delete_scoped_object_key(
|
||||
request_context,
|
||||
file_key,
|
||||
expected_owner_type='plugin_config',
|
||||
)
|
||||
return self.success(data={'deleted': True})
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'failed to delete file: {str(e)}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@@ -1,147 +1,292 @@
|
||||
import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||
class LLMModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
if provider_uuid:
|
||||
return self.success(
|
||||
data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}
|
||||
)
|
||||
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
if provider_uuid:
|
||||
models = await self.ap.llm_model_service.get_llm_models_by_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
else:
|
||||
models = await self.ap.llm_model_service.get_llm_models(
|
||||
request_context,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
return self.success(data={'models': models})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
try:
|
||||
model_uuid = await self.ap.llm_model_service.create_llm_model(
|
||||
request_context,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
model = await self.ap.llm_model_service.get_llm_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
return self.success(data={'model': model})
|
||||
|
||||
return self.success(data={'model': model})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
await self.ap.llm_model_service.update_llm_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success()
|
||||
|
||||
await self.ap.llm_model_service.update_llm_model(model_uuid, json_data)
|
||||
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.llm_model_service.delete_llm_model(model_uuid)
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.llm_model_service.test_llm_model(model_uuid, json_data)
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
await self.ap.llm_model_service.delete_llm_model(request_context, model_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<model_uuid>/test',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
await self.ap.llm_model_service.test_llm_model(request_context, model_uuid, await quart.request.json)
|
||||
return self.success()
|
||||
|
||||
|
||||
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
|
||||
class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
if provider_uuid:
|
||||
return self.success(
|
||||
data={
|
||||
'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
provider_uuid
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
if provider_uuid:
|
||||
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
else:
|
||||
models = await self.ap.embedding_models_service.get_embedding_models(
|
||||
request_context,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
return self.success(data={'models': models})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
try:
|
||||
model_uuid = await self.ap.embedding_models_service.create_embedding_model(
|
||||
request_context,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
model = await self.ap.embedding_models_service.get_embedding_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
return self.success(data={'model': model})
|
||||
|
||||
return self.success(data={'model': model})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
await self.ap.embedding_models_service.update_embedding_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success()
|
||||
|
||||
await self.ap.embedding_models_service.update_embedding_model(model_uuid, json_data)
|
||||
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.embedding_models_service.delete_embedding_model(model_uuid)
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
await self.ap.embedding_models_service.delete_embedding_model(request_context, model_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<model_uuid>/test',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
await self.ap.embedding_models_service.test_embedding_model(
|
||||
request_context, model_uuid, await quart.request.json
|
||||
)
|
||||
return self.success()
|
||||
|
||||
|
||||
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
|
||||
class RerankModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
if provider_uuid:
|
||||
return self.success(
|
||||
data={
|
||||
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
|
||||
}
|
||||
)
|
||||
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
if provider_uuid:
|
||||
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
else:
|
||||
models = await self.ap.rerank_models_service.get_rerank_models(
|
||||
request_context,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
return self.success(data={'models': models})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
try:
|
||||
model_uuid = await self.ap.rerank_models_service.create_rerank_model(
|
||||
request_context,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
|
||||
return self.success(data={'model': model})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
|
||||
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
model = await self.ap.rerank_models_service.get_rerank_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
return self.success(data={'model': model})
|
||||
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
await self.ap.rerank_models_service.update_rerank_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<model_uuid>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
await self.ap.rerank_models_service.delete_rerank_model(request_context, model_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<model_uuid>/test',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
await self.ap.rerank_models_service.test_rerank_model(request_context, model_uuid, await quart.request.json)
|
||||
return self.success()
|
||||
|
||||
@@ -1,56 +1,102 @@
|
||||
import quart
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('models/providers', '/api/v1/provider/providers')
|
||||
class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
providers = await self.ap.provider_service.get_providers()
|
||||
# Add model counts
|
||||
for provider in providers:
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
provider['rerank_count'] = counts['rerank_count']
|
||||
return self.success(data={'providers': providers})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
provider_uuid = await self.ap.provider_service.create_provider(json_data)
|
||||
return self.success(data={'uuid': provider_uuid})
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(provider_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
provider = await self.ap.provider_service.get_provider(provider_uuid)
|
||||
if provider is None:
|
||||
return self.http_status(404, -1, 'provider not found')
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
providers = await self.ap.provider_service.get_providers(
|
||||
request_context,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
for provider in providers:
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
provider['rerank_count'] = counts['rerank_count']
|
||||
return self.success(data={'provider': provider})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.provider_service.update_provider(provider_uuid, json_data)
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
try:
|
||||
await self.ap.provider_service.delete_provider(provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
return self.success(data={'providers': providers})
|
||||
|
||||
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(provider_uuid: str) -> str:
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
provider_uuid = await self.ap.provider_service.create_provider(request_context, json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': provider_uuid})
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
provider = await self.ap.provider_service.get_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if provider is None:
|
||||
return self.http_status(404, -1, 'provider not found')
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider_uuid)
|
||||
provider['llm_count'] = counts['llm_count']
|
||||
provider['embedding_count'] = counts['embedding_count']
|
||||
provider['rerank_count'] = counts['rerank_count']
|
||||
return self.success(data={'provider': provider})
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
try:
|
||||
await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/scan-models',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
model_type = quart.request.args.get('type')
|
||||
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
|
||||
result = await self.ap.provider_service.scan_provider_models(request_context, provider_uuid, model_type)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@@ -1,103 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import traceback
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('mcp', '/api/v1/mcp')
|
||||
class MCPRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取MCP服务器列表"""
|
||||
if quart.request.method == 'GET':
|
||||
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
return self.success(data={'servers': servers})
|
||||
|
||||
elif quart.request.method == 'POST':
|
||||
data = await quart.request.json
|
||||
|
||||
try:
|
||||
uuid = await self.ap.mcp_service.create_mcp_server(data)
|
||||
return self.success(data={'uuid': uuid})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
|
||||
@self.route(
|
||||
'/servers',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
servers = await self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
|
||||
return self.success(data={'servers': servers})
|
||||
|
||||
@self.route(
|
||||
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
|
||||
'/servers',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(server_name: str) -> str:
|
||||
"""获取、更新或删除MCP服务器配置"""
|
||||
server_name = unquote(server_name)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
data = await quart.request.json
|
||||
try:
|
||||
server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'uuid': server_uuid})
|
||||
|
||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
||||
@self.route(
|
||||
'/servers/<path:server_name>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
server_name = unquote(server_name)
|
||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
|
||||
if server_data is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
return self.success(data={'server': server_data})
|
||||
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'server': server_data})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
@self.route(
|
||||
'/servers/<path:server_name>',
|
||||
methods=['PUT', 'DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
server_name = unquote(server_name)
|
||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
|
||||
if server_data is None:
|
||||
return self.http_status(404, -1, 'Server not found')
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data)
|
||||
return self.success()
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}')
|
||||
await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
else:
|
||||
await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid'])
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
try:
|
||||
await self.ap.mcp_service.delete_mcp_server(server_data['uuid'])
|
||||
return self.success()
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
|
||||
|
||||
@self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
@self.route(
|
||||
'/servers/<path:server_name>/test',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
"""测试MCP服务器连接"""
|
||||
server_name = unquote(server_name)
|
||||
server_data = await quart.request.json
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
||||
task_id = await self.ap.mcp_service.test_mcp_server(
|
||||
request_context,
|
||||
server_name=server_name,
|
||||
server_data=server_data,
|
||||
)
|
||||
return self.success(data={'task_id': task_id})
|
||||
|
||||
@self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
@self.route(
|
||||
'/servers/<path:server_name>/resources',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
"""Get resources from an MCP server"""
|
||||
server_name = unquote(server_name)
|
||||
try:
|
||||
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
|
||||
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
|
||||
runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
|
||||
return self.success(
|
||||
data={
|
||||
'resources': resources,
|
||||
'resource_templates': templates,
|
||||
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
|
||||
resources = await self.ap.mcp_service.get_mcp_server_resources(request_context, server_name)
|
||||
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name)
|
||||
runtime_info = await self.ap.mcp_service.get_runtime_info(request_context, server_name)
|
||||
return self.success(
|
||||
data={
|
||||
'resources': resources,
|
||||
'resource_templates': templates,
|
||||
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
|
||||
'/servers/<path:server_name>/resource-templates',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(server_name: str) -> str:
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
"""Get resource templates from an MCP server"""
|
||||
server_name = unquote(server_name)
|
||||
try:
|
||||
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
|
||||
return self.success(data={'resource_templates': templates})
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
||||
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name)
|
||||
return self.success(data={'resource_templates': templates})
|
||||
|
||||
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
@self.route(
|
||||
'/servers/<path:server_name>/logs',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
"""Get logs from an MCP server"""
|
||||
server_name = unquote(server_name)
|
||||
try:
|
||||
@@ -106,24 +133,32 @@ class MCPRouterGroup(group.RouterGroup):
|
||||
limit = 200
|
||||
limit = min(limit, 500)
|
||||
level = quart.request.args.get('level') or None
|
||||
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
|
||||
logs = await self.ap.mcp_service.get_mcp_server_logs(
|
||||
request_context,
|
||||
server_name,
|
||||
limit=limit,
|
||||
level=level,
|
||||
)
|
||||
return self.success(data={'logs': logs})
|
||||
|
||||
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(server_name: str) -> str:
|
||||
@self.route(
|
||||
'/servers/<path:server_name>/resources/read',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(server_name: str, request_context: RequestContext) -> str:
|
||||
"""Read a resource from an MCP server"""
|
||||
server_name = unquote(server_name)
|
||||
data = await quart.request.json
|
||||
uri = data.get('uri')
|
||||
if not uri:
|
||||
return self.http_status(400, -1, 'URI is required')
|
||||
try:
|
||||
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
|
||||
server_name,
|
||||
uri,
|
||||
max_bytes=data.get('max_bytes'),
|
||||
include_blob=bool(data.get('include_blob', False)),
|
||||
)
|
||||
return self.success(data=envelope)
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
|
||||
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
|
||||
request_context,
|
||||
server_name,
|
||||
uri,
|
||||
max_bytes=data.get('max_bytes'),
|
||||
include_blob=bool(data.get('include_blob', False)),
|
||||
)
|
||||
return self.success(data=envelope)
|
||||
|
||||
@@ -2,21 +2,28 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('tools', '/api/v1/tools')
|
||||
class ToolsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""获取所有可用工具列表"""
|
||||
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
||||
bound_plugins: list[str] | None = None
|
||||
bound_mcp_servers: list[str] | None = None
|
||||
|
||||
if pipeline_uuid:
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
|
||||
@@ -35,6 +42,7 @@ class ToolsRouterGroup(group.RouterGroup):
|
||||
return self.success(
|
||||
data={
|
||||
'tools': await self.ap.tool_mgr.get_tool_catalog(
|
||||
request_context,
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=True,
|
||||
@@ -42,10 +50,15 @@ class ToolsRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(tool_name: str) -> str:
|
||||
@self.route(
|
||||
'/<tool_name>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(tool_name: str, request_context: RequestContext) -> str:
|
||||
"""获取特定工具详情"""
|
||||
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
|
||||
tools = await self.ap.tool_mgr.get_all_tools(request_context, include_skill_authoring=True)
|
||||
|
||||
for tool in tools:
|
||||
if tool.name == tool_name:
|
||||
|
||||
@@ -4,6 +4,8 @@ import quart
|
||||
|
||||
from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -12,58 +14,86 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
"""Skills management API endpoints."""
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def list_or_create_skills() -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
try:
|
||||
skills = await self.ap.skill_service.list_skills()
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skills = await self.ap.skill_service.list_skills(request_context)
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def create_skill(request_context: RequestContext) -> quart.Response:
|
||||
data = await quart.request.json
|
||||
if 'name' not in data or not data['name']:
|
||||
return self.http_status(400, -1, 'Missing required field: name')
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.create_skill(data)
|
||||
skill = await self.ap.skill_service.create_skill(request_context, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route('/<skill_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def get_update_delete_skill(skill_name: str) -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
try:
|
||||
skill = await self.ap.skill_service.get_skill(skill_name)
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
return self.success(data={'skill': skill})
|
||||
@self.route(
|
||||
'/<skill_name>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
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:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
return self.success(data={'skill': skill})
|
||||
|
||||
@self.route(
|
||||
'/<skill_name>',
|
||||
methods=['PUT', 'DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
if quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(skill_name, data)
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(skill_name)
|
||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||
return self.success()
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route('/<skill_name>/files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def list_skill_files(skill_name: str) -> quart.Response:
|
||||
@self.route(
|
||||
'/<skill_name>/files',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def list_skill_files(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
"""List files in skill package directory."""
|
||||
path = quart.request.args.get('path', '.').strip()
|
||||
include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true'
|
||||
|
||||
try:
|
||||
result = await self.ap.skill_service.list_skill_files(
|
||||
request_context,
|
||||
skill_name,
|
||||
path=path,
|
||||
include_hidden=include_hidden,
|
||||
@@ -73,38 +103,55 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<skill_name>/files/<path:path>', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'/<skill_name>/files/<path:path>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response:
|
||||
"""Read or write a file in skill package."""
|
||||
if quart.request.method == 'GET':
|
||||
try:
|
||||
result = await self.ap.skill_service.read_skill_file(skill_name, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
async def read_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
|
||||
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:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
# PUT - write file
|
||||
@self.route(
|
||||
'/<skill_name>/files/<path:path>',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def write_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
|
||||
data = await quart.request.json
|
||||
content = data.get('content', '')
|
||||
if content is None:
|
||||
return self.http_status(400, -1, 'Missing required field: content')
|
||||
|
||||
try:
|
||||
result = await self.ap.skill_service.write_skill_file(skill_name, path, content)
|
||||
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:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route('/<skill_name>/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def preview_skill(skill_name: str) -> quart.Response:
|
||||
skill = self.ap.skill_mgr.get_skill_by_name(skill_name)
|
||||
@self.route(
|
||||
'/<skill_name>/preview',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def preview_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
return self.success(data={'instructions': skill.get('instructions', '')})
|
||||
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def install_skill_from_github() -> quart.Response:
|
||||
@self.route(
|
||||
'/install/github',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def install_skill_from_github(request_context: RequestContext) -> quart.Response:
|
||||
data = await quart.request.json
|
||||
required_fields = ['asset_url', 'owner', 'repo']
|
||||
for field in required_fields:
|
||||
@@ -115,15 +162,20 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Missing required field: release_tag')
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(data)
|
||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def preview_skill_from_github() -> quart.Response:
|
||||
@self.route(
|
||||
'/install/github/preview',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def preview_skill_from_github(request_context: RequestContext) -> quart.Response:
|
||||
data = await quart.request.json
|
||||
required_fields = ['asset_url', 'owner', 'repo']
|
||||
for field in required_fields:
|
||||
@@ -134,15 +186,20 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Missing required field: release_tag')
|
||||
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_github(data)
|
||||
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def install_skill_from_upload() -> quart.Response:
|
||||
@self.route(
|
||||
'/install/upload',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def install_skill_from_upload(request_context: RequestContext) -> quart.Response:
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -150,6 +207,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_zip_upload(
|
||||
request_context,
|
||||
file_bytes=file.read(),
|
||||
filename=file.filename or '',
|
||||
source_paths=form.getlist('source_paths'),
|
||||
@@ -157,34 +215,45 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to install skill: {exc}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def preview_skill_from_upload() -> quart.Response:
|
||||
@self.route(
|
||||
'/install/upload/preview',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def preview_skill_from_upload(request_context: RequestContext) -> quart.Response:
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_zip_upload(
|
||||
request_context,
|
||||
file_bytes=file.read(),
|
||||
filename=file.filename or '',
|
||||
)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to preview skill: {exc}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def scan_skill_directory() -> quart.Response:
|
||||
@self.route(
|
||||
'/scan',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def scan_skill_directory(request_context: RequestContext) -> quart.Response:
|
||||
path = quart.request.args.get('path', '').strip()
|
||||
if not path:
|
||||
return self.http_status(400, -1, 'Missing required parameter: path')
|
||||
|
||||
try:
|
||||
result = await self.ap.skill_service.scan_directory_async(path)
|
||||
result = await self.ap.skill_service.scan_directory_async(request_context, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
from .. import group
|
||||
from ...authz import Permission
|
||||
from ...context import ExecutionContext, RequestContext
|
||||
|
||||
|
||||
def collect_basic_stats(ap, request_context: RequestContext) -> dict[str, int]:
|
||||
"""Collect runtime counters only from the selected Workspace placement."""
|
||||
|
||||
execution_context = ExecutionContext.from_request(request_context)
|
||||
sessions = [
|
||||
session
|
||||
for session in ap.sess_mgr.session_list
|
||||
if (
|
||||
getattr(session, 'instance_uuid', None) == execution_context.instance_uuid
|
||||
and getattr(session, 'workspace_uuid', None) == execution_context.workspace_uuid
|
||||
and getattr(session, 'placement_generation', None) == execution_context.placement_generation
|
||||
)
|
||||
]
|
||||
conversation_count = sum(
|
||||
len(session.conversations if session.conversations is not None else []) for session in sessions
|
||||
)
|
||||
return {
|
||||
'active_session_count': len(sessions),
|
||||
'conversation_count': conversation_count,
|
||||
'query_count': ap.query_pool.get_query_count(execution_context),
|
||||
}
|
||||
|
||||
|
||||
@group.group_class('stats', '/api/v1/stats')
|
||||
class StatsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/basic', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
conv_count = 0
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
conv_count += len(session.conversations if session.conversations is not None else [])
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'active_session_count': len(self.ap.sess_mgr.session_list),
|
||||
'conversation_count': conv_count,
|
||||
'query_count': self.ap.query_pool.query_id_counter,
|
||||
}
|
||||
)
|
||||
@self.route(
|
||||
'/basic',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(data=collect_basic_stats(self.ap, request_context))
|
||||
|
||||
@@ -5,7 +5,9 @@ import sqlalchemy
|
||||
|
||||
from .. import group
|
||||
from .....utils import constants
|
||||
from .....entity.persistence.metadata import Metadata
|
||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
@@ -17,17 +19,25 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
wizard_status = 'none'
|
||||
wizard_progress = None
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
|
||||
)
|
||||
for row in result:
|
||||
if row.key == 'wizard_status':
|
||||
wizard_status = row.value
|
||||
elif row.key == 'wizard_progress':
|
||||
try:
|
||||
wizard_progress = json.loads(row.value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
wizard_progress = None
|
||||
authorization = quart.request.headers.get('Authorization', '')
|
||||
if authorization.startswith('Bearer '):
|
||||
account, _ = await self._authenticate_account(authorization.removeprefix('Bearer '))
|
||||
request_context = await self._resolve_account_context(account, group.AuthType.USER_TOKEN)
|
||||
if request_context is not None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(WorkspaceMetadata).where(
|
||||
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
|
||||
WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']),
|
||||
)
|
||||
)
|
||||
for row in result:
|
||||
if row.key == 'wizard_status':
|
||||
wizard_status = row.value
|
||||
elif row.key == 'wizard_progress':
|
||||
try:
|
||||
wizard_progress = json.loads(row.value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
wizard_progress = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -67,8 +77,13 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/wizard/completed',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.WORKSPACE_UPDATE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Mark wizard status in metadata table and clear progress.
|
||||
|
||||
Accepts JSON body: { "status": "skipped" | "completed" }
|
||||
@@ -80,28 +95,48 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
|
||||
sqlalchemy.select(WorkspaceMetadata).where(
|
||||
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_status',
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
|
||||
sqlalchemy.update(WorkspaceMetadata)
|
||||
.where(
|
||||
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_status',
|
||||
)
|
||||
.values(value=status)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
|
||||
sqlalchemy.insert(WorkspaceMetadata).values(
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
key='wizard_status',
|
||||
value=status,
|
||||
)
|
||||
)
|
||||
|
||||
# Clear wizard progress when wizard is completed/skipped
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
|
||||
sqlalchemy.delete(WorkspaceMetadata).where(
|
||||
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_progress',
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/wizard/progress',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.WORKSPACE_UPDATE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Save wizard progress to metadata table.
|
||||
|
||||
Accepts JSON body with wizard state fields:
|
||||
@@ -113,23 +148,40 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
|
||||
sqlalchemy.select(WorkspaceMetadata).where(
|
||||
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_progress',
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
|
||||
sqlalchemy.update(WorkspaceMetadata)
|
||||
.where(
|
||||
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
|
||||
WorkspaceMetadata.key == 'wizard_progress',
|
||||
)
|
||||
.values(value=progress_json)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
|
||||
sqlalchemy.insert(WorkspaceMetadata).values(
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
key='wizard_progress',
|
||||
value=progress_json,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
task_type = quart.request.args.get('type')
|
||||
task_kind = quart.request.args.get('kind')
|
||||
|
||||
@@ -138,30 +190,56 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
if task_kind == '':
|
||||
task_kind = None
|
||||
|
||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||
return self.success(
|
||||
data=self.ap.task_mgr.get_tasks_dict(
|
||||
task_type,
|
||||
task_kind,
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
)
|
||||
)
|
||||
|
||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(task_id: str) -> str:
|
||||
task = self.ap.task_mgr.get_task_by_id(int(task_id))
|
||||
@self.route(
|
||||
'/tasks/<task_id>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(task_id: str, request_context: RequestContext) -> str:
|
||||
task = self.ap.task_mgr.get_task_by_id(
|
||||
int(task_id),
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
)
|
||||
|
||||
if task is None:
|
||||
return self.http_status(404, 404, 'Task not found')
|
||||
|
||||
return self.success(data=task.to_dict())
|
||||
|
||||
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
||||
@self.route(
|
||||
'/storage-analysis',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(data=await self.ap.maintenance_service.get_storage_analysis(request_context))
|
||||
|
||||
@self.route(
|
||||
'/debug/plugin/action',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def _() -> str:
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
if not constants.debug_mode:
|
||||
return self.http_status(403, 403, 'Forbidden')
|
||||
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
data = await quart.request.json
|
||||
|
||||
class AnoymousAction:
|
||||
@@ -174,6 +252,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
AnoymousAction(data['action']),
|
||||
data['data'],
|
||||
timeout=data.get('timeout', 10),
|
||||
action_context=self.ap.plugin_connector.handler.require_bound_action_context().without_installation(),
|
||||
)
|
||||
|
||||
return self.success(data=resp)
|
||||
@@ -182,8 +261,10 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'/status/plugin-system',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _() -> str:
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
plugin_connector_error = 'ok'
|
||||
is_connected = True
|
||||
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import traceback
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from .. import group
|
||||
from .....entity.errors import account as account_errors
|
||||
from ...context import RequestContext
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
|
||||
@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 (
|
||||
parsed.scheme not in {'http', 'https'}
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or parsed.path != '/auth/space/callback'
|
||||
):
|
||||
raise ValueError('Invalid redirect_uri parameter')
|
||||
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
if bind:
|
||||
if query != {'mode': ['bind']}:
|
||||
raise ValueError('Invalid Space binding redirect_uri')
|
||||
elif query:
|
||||
raise ValueError('Invalid Space 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:
|
||||
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
@@ -23,7 +62,12 @@ class UserRouterGroup(group.RouterGroup):
|
||||
user_email = json_data['user']
|
||||
password = json_data['password']
|
||||
|
||||
await self.ap.user_service.create_user(user_email, password)
|
||||
try:
|
||||
await self.ap.user_service.create_user(user_email, password)
|
||||
except ControlPlaneDirectoryRequiredError as exc:
|
||||
return self.http_status(409, exc.code, str(exc))
|
||||
except PublicRegistrationClosedError:
|
||||
return self.http_status(409, 'registration_closed', 'System already initialized')
|
||||
|
||||
return self.success()
|
||||
|
||||
@@ -40,7 +84,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'token': token})
|
||||
|
||||
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
token = await self.ap.user_service.generate_jwt_token(user_email)
|
||||
|
||||
@@ -101,15 +145,37 @@ class UserRouterGroup(group.RouterGroup):
|
||||
async def _() -> str:
|
||||
"""Get Space OAuth authorization URL for redirect"""
|
||||
redirect_uri = quart.request.args.get('redirect_uri', '')
|
||||
state = quart.request.args.get('state', '')
|
||||
|
||||
if not redirect_uri:
|
||||
return self.fail(1, 'Missing redirect_uri parameter')
|
||||
if 'state' in quart.request.args:
|
||||
return self.fail(1, 'Caller-supplied OAuth state is not allowed')
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
|
||||
state = await self.ap.user_service.issue_space_oauth_state('login')
|
||||
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
|
||||
return self.success(data={'authorize_url': authorize_url})
|
||||
except Exception as e:
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Issue an account-bound, one-time Space OAuth redirect."""
|
||||
redirect_uri = quart.request.args.get('redirect_uri', '')
|
||||
if not redirect_uri:
|
||||
return self.fail(1, 'Missing redirect_uri parameter')
|
||||
if not request_context.account_uuid:
|
||||
return self.http_status(403, 'account_required', 'An Account is required')
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=True)
|
||||
state = await self.ap.user_service.issue_space_oauth_state(
|
||||
'bind',
|
||||
account_uuid=request_context.account_uuid,
|
||||
)
|
||||
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
|
||||
return self.success(data={'authorize_url': authorize_url})
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
@@ -117,11 +183,15 @@ class UserRouterGroup(group.RouterGroup):
|
||||
"""Handle OAuth callback - exchange code for tokens and authenticate"""
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
|
||||
if not code:
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
if not state:
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
|
||||
try:
|
||||
await self.ap.user_service.consume_space_oauth_state(state, 'login')
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
access_token = token_data.get('access_token')
|
||||
@@ -142,15 +212,15 @@ class UserRouterGroup(group.RouterGroup):
|
||||
'user': user_obj.user,
|
||||
}
|
||||
)
|
||||
except ControlPlaneDirectoryRequiredError as e:
|
||||
return self.http_status(409, e.code, str(e))
|
||||
except account_errors.AccountEmailMismatchError as e:
|
||||
return self.fail(3, str(e))
|
||||
except ValueError as e:
|
||||
traceback.print_exc()
|
||||
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
|
||||
return self.fail(1, str(e))
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return self.fail(2, f'OAuth callback failed: {str(e)}')
|
||||
except ValueError:
|
||||
self.ap.logger.exception('Space OAuth callback failed')
|
||||
return self.fail(1, 'Space OAuth failed')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
@@ -162,6 +232,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'account_uuid': user_obj.uuid,
|
||||
'user': user_obj.user,
|
||||
'account_type': user_obj.account_type,
|
||||
'has_password': bool(user_obj.password and user_obj.password.strip()),
|
||||
@@ -176,19 +247,18 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Get account info for login page (account type and has_password)"""
|
||||
"""Return instance login capabilities without disclosing an account."""
|
||||
if not await self.ap.user_service.is_initialized():
|
||||
return self.success(data={'initialized': False})
|
||||
|
||||
user_obj = await self.ap.user_service.get_first_user()
|
||||
if user_obj is None:
|
||||
return self.success(data={'initialized': False})
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'initialized': True,
|
||||
'account_type': user_obj.account_type,
|
||||
'has_password': bool(user_obj.password and user_obj.password.strip()),
|
||||
# Login is selected per account in a multi-user instance. A public
|
||||
# bootstrap endpoint must never project one user's authentication
|
||||
# methods onto every other user or disclose that user's state.
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -233,7 +303,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state') # JWT token passed as state
|
||||
state = json_data.get('state')
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
@@ -241,13 +311,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
|
||||
# Verify state is a valid JWT token
|
||||
try:
|
||||
user_email = await self.ap.user_service.verify_jwt_token(state)
|
||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||
except Exception:
|
||||
return self.http_status(401, -1, 'Invalid or expired state')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
@@ -255,8 +322,8 @@ 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_email, code)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
|
||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||
return self.success(
|
||||
data={
|
||||
'token': jwt_token,
|
||||
@@ -264,7 +331,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
'account_type': updated_user.account_type,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')
|
||||
except ValueError:
|
||||
self.ap.logger.exception('Space account binding failed')
|
||||
return self.http_status(400, -1, 'Space account binding failed')
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@@ -1,49 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from ...authz import Permission, has_permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
|
||||
class WebhookManagementRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhooks = await self.ap.webhook_service.get_webhooks()
|
||||
return self.success(data={'webhooks': webhooks})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
url = json_data.get('url', '')
|
||||
description = json_data.get('description', '')
|
||||
enabled = json_data.get('enabled', True)
|
||||
@self.route('', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
webhooks = await self.ap.webhook_service.get_webhooks(
|
||||
request_context,
|
||||
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
|
||||
)
|
||||
return self.success(data={'webhooks': webhooks})
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
if not url:
|
||||
return self.http_status(400, -1, 'URL is required')
|
||||
@self.route('', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.get_json(silent=True) or {}
|
||||
name = json_data.get('name', '')
|
||||
url = json_data.get('url', '')
|
||||
description = json_data.get('description', '')
|
||||
enabled = json_data.get('enabled', True)
|
||||
|
||||
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
|
||||
return self.success(data={'webhook': webhook})
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
if not url:
|
||||
return self.http_status(400, -1, 'URL is required')
|
||||
|
||||
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(webhook_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
try:
|
||||
webhook = await self.ap.webhook_service.create_webhook(
|
||||
request_context,
|
||||
name,
|
||||
url,
|
||||
description,
|
||||
enabled,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
@self.route('/<int:webhook_id>', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def _(webhook_id: int, request_context: RequestContext) -> str:
|
||||
webhook = await self.ap.webhook_service.get_webhook(
|
||||
request_context,
|
||||
webhook_id,
|
||||
include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
|
||||
)
|
||||
if webhook is None:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
@self.route(
|
||||
'/<int:webhook_id>',
|
||||
methods=['PUT', 'DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(webhook_id: int, request_context: RequestContext) -> str:
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.get_json(silent=True) or {}
|
||||
updated = await self.ap.webhook_service.update_webhook(
|
||||
request_context,
|
||||
webhook_id,
|
||||
json_data.get('name'),
|
||||
json_data.get('url'),
|
||||
json_data.get('description'),
|
||||
json_data.get('enabled'),
|
||||
)
|
||||
if not updated:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
url = json_data.get('url')
|
||||
description = json_data.get('description')
|
||||
enabled = json_data.get('enabled')
|
||||
|
||||
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.webhook_service.delete_webhook(webhook_id)
|
||||
return self.success()
|
||||
deleted = await self.ap.webhook_service.delete_webhook(request_context, webhook_id)
|
||||
if not deleted:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success()
|
||||
|
||||
@@ -30,7 +30,10 @@ class WebhookRouterGroup(group.RouterGroup):
|
||||
适配器返回的响应
|
||||
"""
|
||||
try:
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
# Public ingress never accepts X-Workspace-Id. The opaque bot UUID
|
||||
# is resolved against the already-bound runtime resource, which
|
||||
# carries the trusted Workspace and placement generation.
|
||||
runtime_bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
|
||||
|
||||
if not runtime_bot:
|
||||
return quart.jsonify({'error': 'Bot not found'}), 404
|
||||
@@ -49,6 +52,9 @@ class WebhookRouterGroup(group.RouterGroup):
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')
|
||||
return quart.jsonify({'error': str(e)}), 500
|
||||
except Exception:
|
||||
request_id = self.request_id()
|
||||
self.ap.logger.error(
|
||||
f'Webhook dispatch error request_id={request_id} bot={bot_uuid}: {traceback.format_exc()}'
|
||||
)
|
||||
return self.internal_error_response(request_id)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import quart
|
||||
|
||||
from ...authz import Permission, permissions_for_role
|
||||
from ...context import RequestContext
|
||||
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
|
||||
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
|
||||
from .....entity.persistence.workspace import WorkspaceSource
|
||||
from .....workspace.collaboration import WorkspaceMemberView
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .. import group
|
||||
|
||||
|
||||
def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'uuid': workspace.uuid,
|
||||
'instance_uuid': workspace.instance_uuid,
|
||||
'name': workspace.name,
|
||||
'slug': workspace.slug,
|
||||
'type': workspace.type,
|
||||
'status': workspace.status,
|
||||
'source': workspace.source,
|
||||
}
|
||||
|
||||
|
||||
def _membership_payload(
|
||||
membership: WorkspaceMembership,
|
||||
*,
|
||||
email: str,
|
||||
) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'uuid': membership.uuid,
|
||||
'workspace_uuid': membership.workspace_uuid,
|
||||
'account_uuid': membership.account_uuid,
|
||||
'email': email,
|
||||
'role': membership.role,
|
||||
'status': membership.status,
|
||||
'joined_at': membership.joined_at.isoformat() if membership.joined_at else None,
|
||||
'created_at': membership.created_at.isoformat() if membership.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _invitation_payload(invitation: WorkspaceInvitation) -> dict[str, typing.Any]:
|
||||
"""Serialize an invitation without its bearer-secret hash."""
|
||||
|
||||
return {
|
||||
'uuid': invitation.uuid,
|
||||
'workspace_uuid': invitation.workspace_uuid,
|
||||
'normalized_email': invitation.normalized_email,
|
||||
'role': invitation.role,
|
||||
'status': invitation.status,
|
||||
'expires_at': invitation.expires_at.isoformat(),
|
||||
'created_at': invitation.created_at.isoformat() if invitation.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@group.group_class('workspaces', '/api/v1/workspaces')
|
||||
class WorkspacesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
|
||||
async def _(user_email: str) -> typing.Any:
|
||||
"""List the active Workspaces available to an authenticated Account.
|
||||
|
||||
This account-only endpoint intentionally runs before Workspace
|
||||
selection. It never accepts a selector as authority and does not
|
||||
choose a default Workspace for a multi-membership Account.
|
||||
"""
|
||||
|
||||
account = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if account is None:
|
||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
|
||||
return self.success(
|
||||
data={
|
||||
'workspaces': [
|
||||
{
|
||||
'workspace': _workspace_payload(access.workspace),
|
||||
'membership': _membership_payload(access.membership, email=account.user),
|
||||
'permissions': sorted(permissions_for_role(access.membership.role)),
|
||||
'placement_generation': access.execution.placement_generation,
|
||||
}
|
||||
for access in accesses
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('', methods=['GET', 'POST'], permission=Permission.WORKSPACE_VIEW)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
if quart.request.method == 'POST':
|
||||
if self.ap.workspace_service.policy.multi_workspace_enabled:
|
||||
return self.http_status(
|
||||
409,
|
||||
'control_plane_required',
|
||||
'Cloud Workspaces are created by the SaaS control plane',
|
||||
)
|
||||
return self.http_status(403, 'edition_limit', 'This edition supports one Workspace per instance')
|
||||
|
||||
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(
|
||||
request_context.account_uuid
|
||||
)
|
||||
return self.success(data={'workspaces': [_workspace_payload(access.workspace) for access in accesses]})
|
||||
|
||||
@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)
|
||||
return self.success(
|
||||
data={
|
||||
'workspace': _workspace_payload(workspace),
|
||||
'membership': _membership_payload(membership, email=account.user),
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/<workspace_uuid>', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
|
||||
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
|
||||
return self.success(data={'workspace': _workspace_payload(workspace)})
|
||||
|
||||
@self.route('/<workspace_uuid>/members', methods=['GET'], permission=Permission.MEMBER_VIEW)
|
||||
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
members = await self.ap.workspace_collaboration_service.list_members(
|
||||
workspace_uuid,
|
||||
quart.g.workspace_membership,
|
||||
)
|
||||
return self.success(data={'members': [self._member_view_payload(item) for item in members]})
|
||||
|
||||
@self.route(
|
||||
'/<workspace_uuid>/invitations',
|
||||
methods=['GET', 'POST'],
|
||||
permission=Permission.MEMBER_INVITE,
|
||||
)
|
||||
async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
if await self._requires_control_plane(workspace_uuid):
|
||||
return self._control_plane_required()
|
||||
if quart.request.method == 'GET':
|
||||
invitations = await self.ap.workspace_collaboration_service.list_invitations(
|
||||
workspace_uuid,
|
||||
quart.g.workspace_membership,
|
||||
)
|
||||
return self.success(data={'invitations': [_invitation_payload(item) for item in invitations]})
|
||||
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
created = await self.ap.workspace_collaboration_service.create_invitation(
|
||||
workspace_uuid,
|
||||
quart.g.workspace_membership,
|
||||
str(data.get('email', '')),
|
||||
str(data.get('role', 'viewer')),
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'invitation': _invitation_payload(created.invitation),
|
||||
'token': created.token,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/<workspace_uuid>/invitations/<invitation_uuid>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.MEMBER_INVITE,
|
||||
)
|
||||
async def _(
|
||||
workspace_uuid: str,
|
||||
invitation_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
if await self._requires_control_plane(workspace_uuid):
|
||||
return self._control_plane_required()
|
||||
invitation = await self.ap.workspace_collaboration_service.revoke_invitation(
|
||||
workspace_uuid,
|
||||
invitation_uuid,
|
||||
quart.g.workspace_membership,
|
||||
)
|
||||
return self.success(data={'invitation': _invitation_payload(invitation)})
|
||||
|
||||
@self.route(
|
||||
'/<workspace_uuid>/members/<account_uuid>',
|
||||
methods=['PATCH', 'DELETE'],
|
||||
permission=Permission.MEMBER_UPDATE_ROLE,
|
||||
)
|
||||
async def _(
|
||||
workspace_uuid: str,
|
||||
account_uuid: str,
|
||||
request_context: RequestContext,
|
||||
) -> typing.Any:
|
||||
self._require_current_workspace(workspace_uuid, request_context)
|
||||
if await self._requires_control_plane(workspace_uuid):
|
||||
return self._control_plane_required()
|
||||
if quart.request.method == 'DELETE':
|
||||
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
|
||||
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
|
||||
member = await self.ap.workspace_collaboration_service.remove_member(
|
||||
workspace_uuid,
|
||||
account_uuid,
|
||||
quart.g.workspace_membership,
|
||||
)
|
||||
return self.success(data={'account_uuid': member.account_uuid})
|
||||
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
member = await self.ap.workspace_collaboration_service.update_member_role(
|
||||
workspace_uuid,
|
||||
account_uuid,
|
||||
str(data.get('role', '')),
|
||||
quart.g.workspace_membership,
|
||||
)
|
||||
account = await self.ap.user_service.get_user_by_uuid(member.account_uuid)
|
||||
return self.success(
|
||||
data={
|
||||
'member': _membership_payload(
|
||||
member,
|
||||
email=account.user if account is not None else '',
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None:
|
||||
if workspace_uuid != request_context.workspace_uuid:
|
||||
raise WorkspaceNotFoundError('Workspace not found')
|
||||
|
||||
async def _requires_control_plane(self, workspace_uuid: str) -> bool:
|
||||
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
|
||||
return workspace.source == WorkspaceSource.CLOUD_PROJECTION.value
|
||||
|
||||
def _control_plane_required(self) -> typing.Any:
|
||||
return self.http_status(
|
||||
409,
|
||||
'control_plane_required',
|
||||
'Cloud Workspace membership and invitations are managed by the SaaS control plane',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
|
||||
return _membership_payload(view.membership, email=view.email)
|
||||
|
||||
|
||||
@group.group_class('invitations', '/api/v1/invitations')
|
||||
class InvitationsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/inspect', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> typing.Any:
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
invitation, workspace = await self.ap.workspace_collaboration_service.inspect_invitation(
|
||||
str(data.get('token', ''))
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'invitation': _invitation_payload(invitation),
|
||||
'workspace': _workspace_payload(workspace),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/accept', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> typing.Any:
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
invitation_token = str(data.get('token', ''))
|
||||
if not invitation_token:
|
||||
return self.http_status(400, 'invitation_invalid', 'Invitation token is required')
|
||||
|
||||
authorization = quart.request.headers.get('Authorization', '')
|
||||
if authorization.startswith('Bearer '):
|
||||
account = await self.ap.user_service.get_authenticated_account(authorization.removeprefix('Bearer '))
|
||||
if isinstance(account, str):
|
||||
account = await self.ap.user_service.get_user_by_email(account)
|
||||
if account is None:
|
||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||
membership = await self.ap.workspace_collaboration_service.accept_invitation(
|
||||
invitation_token,
|
||||
account.uuid,
|
||||
)
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
|
||||
|
||||
registration = data.get('registration')
|
||||
if not isinstance(registration, dict):
|
||||
return self.http_status(
|
||||
401,
|
||||
'account_exists_login_required',
|
||||
'Sign in or provide registration details to accept this invitation',
|
||||
)
|
||||
password = registration.get('password')
|
||||
if not isinstance(password, str) or len(password) < 8:
|
||||
return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters')
|
||||
try:
|
||||
_, membership, token = await self.ap.user_service.register_invited_account(
|
||||
invitation_token,
|
||||
str(registration.get('email', '')),
|
||||
password,
|
||||
)
|
||||
except ControlPlaneDirectoryRequiredError as exc:
|
||||
return self.http_status(409, exc.code, str(exc))
|
||||
except AccountExistsLoginRequiredError as exc:
|
||||
return self.http_status(409, exc.code, str(exc))
|
||||
return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
|
||||
@@ -1,97 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import hashlib
|
||||
import secrets
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import apikey
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..authz import Permission, PermissionDeniedError
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class ApiKeyIdentity:
|
||||
"""Trusted Workspace identity derived from an API-key secret."""
|
||||
|
||||
instance_uuid: str
|
||||
workspace_uuid: str
|
||||
placement_generation: int
|
||||
api_key_uuid: str
|
||||
permissions: frozenset[str]
|
||||
|
||||
|
||||
class ApiKeyService:
|
||||
ap: app.Application
|
||||
"""Manage hashed, Workspace-bound API keys."""
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
def __init__(self, ap: Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_api_keys(self) -> list[dict]:
|
||||
"""Get all API keys"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))
|
||||
@staticmethod
|
||||
def _hash_secret(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
||||
|
||||
keys = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]
|
||||
@staticmethod
|
||||
def _utcnow() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
|
||||
async def create_api_key(self, name: str, description: str = '') -> dict:
|
||||
"""Create a new API key"""
|
||||
# Generate a secure random API key
|
||||
key = f'lbk_{secrets.token_urlsafe(32)}'
|
||||
@staticmethod
|
||||
def _normalize_scopes(
|
||||
scopes: typing.Iterable[str] | None,
|
||||
*,
|
||||
default: typing.Iterable[str] = (),
|
||||
) -> list[str]:
|
||||
requested = list(default if scopes is None else scopes)
|
||||
valid = {permission.value for permission in Permission}
|
||||
normalized: list[str] = []
|
||||
for scope in requested:
|
||||
if not isinstance(scope, str):
|
||||
raise ValueError('API key scopes must be strings')
|
||||
value = scope.strip()
|
||||
if value not in valid:
|
||||
raise ValueError(f'Unknown API key scope: {value}')
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
key_data = {'name': name, 'key': key, 'description': description}
|
||||
def _serialize(self, row: typing.Any) -> dict[str, typing.Any]:
|
||||
value = self.ap.persistence_mgr.serialize_model(apikey.ApiKey, row)
|
||||
value.pop('key_hash', None)
|
||||
# The secret is deliberately unrecoverable after creation.
|
||||
value['secret_available'] = False
|
||||
return value
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))
|
||||
|
||||
# Retrieve the created key
|
||||
async def get_api_keys(self, context: TenantContext) -> list[dict[str, typing.Any]]:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||
scope_statement(
|
||||
sqlalchemy.select(apikey.ApiKey).order_by(apikey.ApiKey.created_at, apikey.ApiKey.id),
|
||||
apikey.ApiKey,
|
||||
context,
|
||||
)
|
||||
)
|
||||
created_key = result.first()
|
||||
return [self._serialize(key) for key in result.all()]
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)
|
||||
async def create_api_key(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
description: str = '',
|
||||
*,
|
||||
scopes: typing.Iterable[str] | None = None,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError('Name is required')
|
||||
if expires_at is not None:
|
||||
if expires_at.tzinfo is not None:
|
||||
expires_at = expires_at.astimezone(datetime.UTC).replace(tzinfo=None)
|
||||
if expires_at <= self._utcnow():
|
||||
raise ValueError('API key expiry must be in the future')
|
||||
|
||||
async def get_api_key(self, key_id: int) -> dict | None:
|
||||
"""Get a specific API key by ID"""
|
||||
default_scopes = getattr(getattr(context, 'workspace', None), 'permissions', frozenset())
|
||||
normalized_scopes = self._normalize_scopes(scopes, default=default_scopes)
|
||||
allowed_scopes = frozenset(default_scopes)
|
||||
unauthorized_scopes = sorted(set(normalized_scopes) - allowed_scopes)
|
||||
if unauthorized_scopes:
|
||||
# API-key management delegates the caller's authority; it must not
|
||||
# become a path for minting a stronger principal.
|
||||
raise PermissionDeniedError(unauthorized_scopes[0])
|
||||
secret = f'lbk_{secrets.token_urlsafe(32)}'
|
||||
key_uuid = str(uuid.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(apikey.ApiKey).values(
|
||||
uuid=key_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
created_by_account_uuid=getattr(context, 'account_uuid', None),
|
||||
name=normalized_name,
|
||||
key_hash=self._hash_secret(secret),
|
||||
scopes=normalized_scopes,
|
||||
status=apikey.ApiKeyStatus.ACTIVE.value,
|
||||
expires_at=expires_at,
|
||||
description=description.strip(),
|
||||
)
|
||||
)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
|
||||
scope_statement(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.uuid == key_uuid),
|
||||
apikey.ApiKey,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
created = result.first()
|
||||
if created is None:
|
||||
raise RuntimeError('Created API key could not be loaded')
|
||||
value = self._serialize(created)
|
||||
value['key'] = secret
|
||||
value['secret_available'] = True
|
||||
return value
|
||||
|
||||
async def get_api_key(self, context: TenantContext, key_id: int) -> dict[str, typing.Any] | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id),
|
||||
apikey.ApiKey,
|
||||
context,
|
||||
)
|
||||
)
|
||||
key = result.first()
|
||||
return None if key is None else self._serialize(key)
|
||||
|
||||
if key is None:
|
||||
async def authenticate_api_key(self, secret: str) -> ApiKeyIdentity | None:
|
||||
"""Authenticate a secret and derive its Workspace without trusting headers."""
|
||||
|
||||
if not isinstance(secret, str) or not secret.strip():
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
|
||||
|
||||
async def verify_api_key(self, key: str) -> bool:
|
||||
"""Verify if an API key is valid.
|
||||
|
||||
A key is accepted if it matches the global API key configured in
|
||||
``config.yaml`` (``api.global_api_key``) — which requires no login
|
||||
session and no database record — or if it matches a key created via
|
||||
the web UI (stored in the database, prefixed with ``lbk_``).
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
return False
|
||||
|
||||
# 1. Global API key from config.yaml (no DB lookup, no login state).
|
||||
# Note: config completion only backfills top-level keys, so existing
|
||||
# installs may not have this key — access it defensively.
|
||||
global_api_key = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
|
||||
if global_api_key and secrets.compare_digest(key, global_api_key):
|
||||
return True
|
||||
|
||||
# 2. Web-UI-created keys are stored in the database and prefixed lbk_.
|
||||
if not key.startswith('lbk_'):
|
||||
return False
|
||||
global_secret = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
|
||||
if global_secret and secrets.compare_digest(secret, global_secret):
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
if workspace_service is None or workspace_service.policy.multi_workspace_enabled:
|
||||
return None
|
||||
binding = await workspace_service.get_local_execution_binding()
|
||||
return ApiKeyIdentity(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
api_key_uuid='global-oss-api-key',
|
||||
permissions=frozenset(permission.value for permission in Permission),
|
||||
)
|
||||
|
||||
if not secret.startswith('lbk_'):
|
||||
return None
|
||||
secret_hash = self._hash_secret(secret)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key_hash == secret_hash)
|
||||
)
|
||||
key = result.first()
|
||||
if key is None or key.status != apikey.ApiKeyStatus.ACTIVE.value:
|
||||
return None
|
||||
now = self._utcnow()
|
||||
if key.expires_at is not None and key.expires_at <= now:
|
||||
return None
|
||||
|
||||
raw_scopes = list(key.scopes or [])
|
||||
permissions = (
|
||||
frozenset(permission.value for permission in Permission)
|
||||
if '*' in raw_scopes
|
||||
else frozenset(self._normalize_scopes(raw_scopes))
|
||||
)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(key.workspace_uuid)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey)
|
||||
.where(
|
||||
apikey.ApiKey.id == key.id,
|
||||
apikey.ApiKey.workspace_uuid == key.workspace_uuid,
|
||||
apikey.ApiKey.key_hash == secret_hash,
|
||||
)
|
||||
.values(last_used_at=now)
|
||||
)
|
||||
return ApiKeyIdentity(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
api_key_uuid=key.uuid,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
key_obj = result.first()
|
||||
return key_obj is not None
|
||||
async def verify_api_key(self, secret: str) -> bool:
|
||||
try:
|
||||
return await self.authenticate_api_key(secret) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def delete_api_key(self, key_id: int) -> None:
|
||||
"""Delete an API key"""
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id))
|
||||
|
||||
async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
|
||||
"""Update an API key's metadata (name, description)"""
|
||||
update_data = {}
|
||||
if name is not None:
|
||||
update_data['name'] = name
|
||||
if description is not None:
|
||||
update_data['description'] = description
|
||||
|
||||
if update_data:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)
|
||||
async def delete_api_key(self, context: TenantContext, key_id: int) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(apikey.ApiKey)
|
||||
.where(apikey.ApiKey.id == key_id)
|
||||
.values(status=apikey.ApiKeyStatus.REVOKED.value),
|
||||
apikey.ApiKey,
|
||||
context,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', 0) == 0:
|
||||
raise WorkspaceNotFoundError('API key not found')
|
||||
|
||||
async def update_api_key(
|
||||
self,
|
||||
context: TenantContext,
|
||||
key_id: int,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
update_data: dict[str, typing.Any] = {}
|
||||
if name is not None:
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError('Name is required')
|
||||
update_data['name'] = normalized_name
|
||||
if description is not None:
|
||||
update_data['description'] = description.strip()
|
||||
if not update_data:
|
||||
return
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data),
|
||||
apikey.ApiKey,
|
||||
context,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', 0) == 0:
|
||||
raise WorkspaceNotFoundError('API key not found')
|
||||
|
||||
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import sqlalchemy
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
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
|
||||
|
||||
|
||||
class BotService:
|
||||
@@ -17,9 +18,11 @@ class BotService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_bots(self, include_secret: bool = True) -> list[dict]:
|
||||
async def get_bots(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
||||
"""获取所有机器人"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(sqlalchemy.select(persistence_bot.Bot), persistence_bot.Bot, context)
|
||||
)
|
||||
|
||||
bots = result.all()
|
||||
|
||||
@@ -29,10 +32,14 @@ class BotService:
|
||||
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots]
|
||||
|
||||
async def get_bot(self, bot_uuid: str, include_secret: bool = True) -> dict | None:
|
||||
async def get_bot(self, context: TenantContext, bot_uuid: str, include_secret: bool = False) -> dict | None:
|
||||
"""获取机器人"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid),
|
||||
persistence_bot.Bot,
|
||||
context,
|
||||
)
|
||||
)
|
||||
|
||||
bot = result.first()
|
||||
@@ -46,15 +53,20 @@ class BotService:
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns)
|
||||
|
||||
async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) -> dict:
|
||||
async def get_runtime_bot_info(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_uuid: str,
|
||||
include_secret: bool = False,
|
||||
) -> dict:
|
||||
"""获取机器人运行时信息"""
|
||||
persistence_bot = await self.get_bot(bot_uuid, include_secret)
|
||||
persistence_bot = await self.get_bot(context, bot_uuid, include_secret)
|
||||
if persistence_bot is None:
|
||||
raise Exception('Bot not found')
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
|
||||
adapter_runtime_values = {}
|
||||
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||
if runtime_bot is not None:
|
||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||
|
||||
@@ -86,22 +98,29 @@ class BotService:
|
||||
|
||||
return persistence_bot
|
||||
|
||||
async def create_bot(self, bot_data: dict) -> str:
|
||||
async def create_bot(self, context: TenantContext, bot_data: dict) -> str:
|
||||
"""Create bot"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_bots = limitation.get('max_bots', -1)
|
||||
if max_bots >= 0:
|
||||
existing_bots = await self.get_bots()
|
||||
existing_bots = await self.get_bots(context)
|
||||
if len(existing_bots) >= max_bots:
|
||||
raise ValueError(f'Maximum number of bots ({max_bots}) reached')
|
||||
|
||||
# TODO: 检查配置信息格式
|
||||
bot_data = bot_data.copy()
|
||||
bot_data['uuid'] = str(uuid.uuid4())
|
||||
bot_data['workspace_uuid'] = workspace_uuid
|
||||
|
||||
# bind the most recently updated pipeline if any exist
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
context,
|
||||
)
|
||||
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -112,61 +131,84 @@ class BotService:
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
|
||||
|
||||
bot = await self.get_bot(bot_data['uuid'])
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
await self.ap.platform_mgr.load_bot(bot)
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
|
||||
return bot_data['uuid']
|
||||
|
||||
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
||||
async def update_bot(self, context: TenantContext, bot_uuid: str, bot_data: dict) -> None:
|
||||
"""Update bot"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
update_data = bot_data.copy()
|
||||
|
||||
if 'uuid' in update_data:
|
||||
del update_data['uuid']
|
||||
update_data.pop('uuid', None)
|
||||
update_data.pop('workspace_uuid', None)
|
||||
|
||||
# set use_pipeline_name
|
||||
if 'use_pipeline_uuid' in update_data:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
||||
),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
pipeline = result.first()
|
||||
if pipeline is not None:
|
||||
update_data['use_pipeline_name'] = pipeline.name
|
||||
else:
|
||||
raise Exception('Pipeline not found')
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid),
|
||||
persistence_bot.Bot,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||
|
||||
# select from db
|
||||
bot = await self.get_bot(bot_uuid)
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
|
||||
runtime_bot = await self.ap.platform_mgr.load_bot(bot)
|
||||
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
|
||||
|
||||
if runtime_bot.enable:
|
||||
await runtime_bot.run()
|
||||
|
||||
# update all conversation that use this bot
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid:
|
||||
if (
|
||||
session.using_conversation is not None
|
||||
and session.using_conversation.bot_uuid == bot_uuid
|
||||
and getattr(session, 'workspace_uuid', workspace_uuid) == workspace_uuid
|
||||
):
|
||||
session.using_conversation = None
|
||||
|
||||
async def delete_bot(self, bot_uuid: str) -> None:
|
||||
async def delete_bot(self, context: TenantContext, bot_uuid: str) -> None:
|
||||
"""Delete bot"""
|
||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid),
|
||||
persistence_bot.Bot,
|
||||
context,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||
|
||||
async def list_event_logs(
|
||||
self, bot_uuid: str, from_index: int, max_count: int
|
||||
) -> typing.Tuple[list[dict], int, int, int]:
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
self, context: TenantContext, bot_uuid: str, from_index: int, max_count: int
|
||||
) -> tuple[list[dict], int]:
|
||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||
if runtime_bot is None:
|
||||
raise Exception('Bot not found')
|
||||
|
||||
@@ -174,7 +216,14 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
|
||||
async def send_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_uuid: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message_chain_data: dict,
|
||||
) -> None:
|
||||
"""Send message to a specific target via bot
|
||||
|
||||
Args:
|
||||
@@ -183,11 +232,14 @@ class BotService:
|
||||
target_id: The ID of the target
|
||||
message_chain_data: The message chain data in dict format
|
||||
"""
|
||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
|
||||
# Import here to avoid circular imports
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
# Get runtime bot
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||
if runtime_bot is None:
|
||||
raise Exception(f'Bot not found: {bot_uuid}')
|
||||
|
||||
@@ -202,19 +254,29 @@ class BotService:
|
||||
|
||||
# ============ Bot Admins ============
|
||||
|
||||
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
|
||||
async def get_bot_admins(self, context: TenantContext, bot_uuid: str) -> list[dict]:
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
|
||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid),
|
||||
persistence_bot.BotAdmin,
|
||||
context,
|
||||
)
|
||||
)
|
||||
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
|
||||
|
||||
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
|
||||
async def add_bot_admin(self, context: TenantContext, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_bot.BotAdmin).values(
|
||||
workspace_uuid=workspace_uuid,
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
@@ -222,12 +284,18 @@ class BotService:
|
||||
)
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
|
||||
async def delete_bot_admin(self, context: TenantContext, bot_uuid: str, admin_id: int) -> None:
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
|
||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bot.BotAdmin).where(
|
||||
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
|
||||
persistence_bot.BotAdmin.id == admin_id,
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_bot.BotAdmin).where(
|
||||
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
|
||||
persistence_bot.BotAdmin.id == admin_id,
|
||||
),
|
||||
persistence_bot.BotAdmin,
|
||||
context,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,8 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....api.http.authz import WorkspaceRequiredError
|
||||
from ....api.http.context import ExecutionContext, RequestContext
|
||||
from ....core import app
|
||||
from ....entity.persistence import rag as persistence_rag
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .secrets import redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
|
||||
class KnowledgeService:
|
||||
@@ -14,16 +19,41 @@ class KnowledgeService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_knowledge_bases(self) -> list[dict]:
|
||||
@staticmethod
|
||||
def _execution_context(context: RequestContext | ExecutionContext) -> ExecutionContext:
|
||||
if isinstance(context, RequestContext):
|
||||
return ExecutionContext.from_request(context)
|
||||
if isinstance(context, ExecutionContext):
|
||||
return context
|
||||
raise WorkspaceRequiredError('RequestContext or ExecutionContext is required')
|
||||
|
||||
async def get_knowledge_bases(self, context: TenantContext, *, include_secret: bool = False) -> list[dict]:
|
||||
"""获取所有知识库"""
|
||||
return await self.ap.rag_mgr.get_all_knowledge_base_details()
|
||||
require_workspace_uuid(context)
|
||||
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
|
||||
return knowledge_bases if include_secret else [redact_secrets(base) for base in knowledge_bases]
|
||||
|
||||
async def get_knowledge_base(self, kb_uuid: str) -> dict | None:
|
||||
async def get_knowledge_base(
|
||||
self,
|
||||
context: TenantContext,
|
||||
kb_uuid: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
"""获取知识库"""
|
||||
return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)
|
||||
require_workspace_uuid(context)
|
||||
knowledge_base = await self.ap.rag_mgr.get_knowledge_base_details(context, kb_uuid)
|
||||
if knowledge_base is None or include_secret:
|
||||
return knowledge_base
|
||||
return redact_secrets(knowledge_base)
|
||||
|
||||
async def create_knowledge_base(self, kb_data: dict) -> str:
|
||||
async def create_knowledge_base(
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
kb_data: dict,
|
||||
) -> str:
|
||||
"""创建知识库"""
|
||||
require_workspace_uuid(context)
|
||||
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
||||
# Legacy internal KB creation is removed.
|
||||
|
||||
@@ -31,17 +61,19 @@ class KnowledgeService:
|
||||
if not knowledge_engine_plugin_id:
|
||||
raise ValueError('knowledge_engine_plugin_id is required')
|
||||
|
||||
creation_settings = kb_data.get('creation_settings', {})
|
||||
creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {}))
|
||||
retrieval_settings = kb_data.get('retrieval_settings', {})
|
||||
|
||||
# Validate required fields based on plugin's creation_schema and retrieval_schema
|
||||
await self._validate_schema_required_fields(
|
||||
context,
|
||||
knowledge_engine_plugin_id,
|
||||
creation_settings,
|
||||
retrieval_settings,
|
||||
)
|
||||
|
||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
||||
context,
|
||||
name=kb_data.get('name', 'Untitled'),
|
||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
||||
creation_settings=creation_settings,
|
||||
@@ -52,6 +84,7 @@ class KnowledgeService:
|
||||
|
||||
async def _validate_schema_required_fields(
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
plugin_id: str,
|
||||
creation_settings: dict,
|
||||
retrieval_settings: dict,
|
||||
@@ -69,7 +102,11 @@ class KnowledgeService:
|
||||
Raises:
|
||||
ValueError: If any required field is missing or empty.
|
||||
"""
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
return
|
||||
|
||||
# Validate creation_schema
|
||||
await self.ap.plugin_connector.require_workspace_context(context)
|
||||
try:
|
||||
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
||||
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
|
||||
@@ -79,6 +116,7 @@ class KnowledgeService:
|
||||
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
|
||||
|
||||
# Validate retrieval_schema
|
||||
await self.ap.plugin_connector.require_workspace_context(context)
|
||||
try:
|
||||
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
||||
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
|
||||
@@ -151,8 +189,16 @@ class KnowledgeService:
|
||||
)
|
||||
raise ValueError(f'{field_label} is required ({context}.{field_name})')
|
||||
|
||||
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||
async def update_knowledge_base(
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
kb_uuid: str,
|
||||
kb_data: dict,
|
||||
) -> None:
|
||||
"""更新知识库"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if await self.get_knowledge_base(context, kb_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
# Filter to only mutable fields
|
||||
filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS}
|
||||
|
||||
@@ -162,17 +208,18 @@ class KnowledgeService:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_rag.KnowledgeBase)
|
||||
.values(filtered_data)
|
||||
.where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
|
||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid)
|
||||
await self.ap.rag_mgr.remove_knowledge_base_from_runtime(context, kb_uuid)
|
||||
|
||||
kb = await self.get_knowledge_base(kb_uuid)
|
||||
kb = await self.get_knowledge_base(context, kb_uuid, include_secret=True)
|
||||
if kb is None:
|
||||
raise Exception('Knowledge base not found after update')
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
|
||||
await self.ap.rag_mgr.load_knowledge_base(kb)
|
||||
await self.ap.rag_mgr.load_knowledge_base(context, kb)
|
||||
|
||||
async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None:
|
||||
async def _check_doc_capability(self, context: TenantContext, kb_uuid: str, operation: str) -> None:
|
||||
"""Check if the KB's Knowledge Engine supports document operations.
|
||||
|
||||
Args:
|
||||
@@ -182,104 +229,145 @@ class KnowledgeService:
|
||||
Raises:
|
||||
Exception: If the KB does not support doc_ingestion.
|
||||
"""
|
||||
kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)
|
||||
kb_info = await self.ap.rag_mgr.get_knowledge_base_details(context, kb_uuid)
|
||||
if not kb_info:
|
||||
raise Exception('Knowledge base not found')
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', [])
|
||||
if 'doc_ingestion' not in capabilities:
|
||||
raise Exception(f'This knowledge base does not support {operation}')
|
||||
|
||||
async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str:
|
||||
async def store_file(
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
kb_uuid: str,
|
||||
file_id: str,
|
||||
parser_plugin_id: str | None = None,
|
||||
) -> str:
|
||||
"""存储文件"""
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
execution_context = self._execution_context(context)
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
|
||||
if runtime_kb is None:
|
||||
raise Exception('Knowledge base not found')
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
|
||||
await self._check_doc_capability(kb_uuid, 'document upload')
|
||||
await self._check_doc_capability(context, kb_uuid, 'document upload')
|
||||
|
||||
result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id)
|
||||
result = await runtime_kb.store_file(execution_context, file_id, parser_plugin_id=parser_plugin_id)
|
||||
|
||||
# Update the KB's updated_at timestamp
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_rag.KnowledgeBase)
|
||||
.values(updated_at=sqlalchemy.func.now())
|
||||
.where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
|
||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def retrieve_knowledge_base(
|
||||
self, kb_uuid: str, query: str, retrieval_settings: dict | None = None
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
kb_uuid: str,
|
||||
query: str,
|
||||
retrieval_settings: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""检索知识库"""
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
execution_context = self._execution_context(context)
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
|
||||
if runtime_kb is None:
|
||||
raise Exception('Knowledge base not found')
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
|
||||
# Pass retrieval_settings
|
||||
results = await runtime_kb.retrieve(query, settings=retrieval_settings)
|
||||
results = await runtime_kb.retrieve(execution_context, query, settings=retrieval_settings)
|
||||
|
||||
return [result.model_dump() for result in results]
|
||||
|
||||
async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]:
|
||||
async def get_files_by_knowledge_base(self, context: TenantContext, kb_uuid: str) -> list[dict]:
|
||||
"""获取知识库文件"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if await self.get_knowledge_base(context, kb_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)
|
||||
sqlalchemy.select(persistence_rag.File)
|
||||
.where(persistence_rag.File.workspace_uuid == workspace_uuid)
|
||||
.where(persistence_rag.File.kb_id == kb_uuid)
|
||||
)
|
||||
files = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_rag.File, file) for file in files]
|
||||
|
||||
async def delete_file(self, kb_uuid: str, file_id: str) -> None:
|
||||
async def delete_file(
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
kb_uuid: str,
|
||||
file_id: str,
|
||||
) -> None:
|
||||
"""删除文件"""
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
execution_context = self._execution_context(context)
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
|
||||
if runtime_kb is None:
|
||||
raise Exception('Knowledge base not found')
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
|
||||
await self._check_doc_capability(kb_uuid, 'document deletion')
|
||||
await self._check_doc_capability(context, kb_uuid, 'document deletion')
|
||||
|
||||
await runtime_kb.delete_file(file_id)
|
||||
await runtime_kb.delete_file(execution_context, file_id)
|
||||
|
||||
# Update the KB's updated_at timestamp
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_rag.KnowledgeBase)
|
||||
.values(updated_at=sqlalchemy.func.now())
|
||||
.where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
|
||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
|
||||
async def delete_knowledge_base(self, kb_uuid: str) -> None:
|
||||
async def delete_knowledge_base(
|
||||
self,
|
||||
context: RequestContext | ExecutionContext,
|
||||
kb_uuid: str,
|
||||
) -> None:
|
||||
"""删除知识库"""
|
||||
# Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if await self.get_knowledge_base(context, kb_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
||||
|
||||
# delete files
|
||||
# NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally.
|
||||
# For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op.
|
||||
files = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)
|
||||
sqlalchemy.select(persistence_rag.File)
|
||||
.where(persistence_rag.File.workspace_uuid == workspace_uuid)
|
||||
.where(persistence_rag.File.kb_id == kb_uuid)
|
||||
)
|
||||
for file in files:
|
||||
# delete chunks
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file.uuid)
|
||||
sqlalchemy.delete(persistence_rag.Chunk)
|
||||
.where(persistence_rag.Chunk.workspace_uuid == workspace_uuid)
|
||||
.where(persistence_rag.Chunk.file_id == file.uuid)
|
||||
)
|
||||
# delete file
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid)
|
||||
sqlalchemy.delete(persistence_rag.File)
|
||||
.where(persistence_rag.File.workspace_uuid == workspace_uuid)
|
||||
.where(persistence_rag.File.uuid == file.uuid)
|
||||
)
|
||||
|
||||
# Remove from runtime and notify plugin (best-effort, DB is already cleaned up)
|
||||
await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)
|
||||
# Remove from runtime and notify plugin before deleting the owning row.
|
||||
await self.ap.rag_mgr.delete_knowledge_base(context, kb_uuid)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_rag.KnowledgeBase)
|
||||
.where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
|
||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
|
||||
# ================= Knowledge Engine Discovery =================
|
||||
|
||||
async def list_knowledge_engines(self) -> list[dict]:
|
||||
async def list_knowledge_engines(self, context: TenantContext) -> list[dict]:
|
||||
"""List all available Knowledge Engines from plugins."""
|
||||
require_workspace_uuid(context)
|
||||
engines = []
|
||||
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
return engines
|
||||
await self.ap.plugin_connector.require_workspace_context(context)
|
||||
|
||||
# Get KnowledgeEngine plugins
|
||||
try:
|
||||
@@ -290,10 +378,12 @@ class KnowledgeService:
|
||||
|
||||
return engines
|
||||
|
||||
async def list_parsers(self, mime_type: str | None = None) -> list[dict]:
|
||||
async def list_parsers(self, context: TenantContext, mime_type: str | None = None) -> list[dict]:
|
||||
"""List available parsers, optionally filtered by MIME type."""
|
||||
require_workspace_uuid(context)
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
return []
|
||||
await self.ap.plugin_connector.require_workspace_context(context)
|
||||
try:
|
||||
parsers = await self.ap.plugin_connector.list_parsers()
|
||||
if mime_type:
|
||||
@@ -303,16 +393,24 @@ class KnowledgeService:
|
||||
self.ap.logger.warning(f'Failed to list parsers: {e}')
|
||||
return []
|
||||
|
||||
async def get_engine_creation_schema(self, plugin_id: str) -> dict:
|
||||
async def get_engine_creation_schema(self, context: TenantContext, plugin_id: str) -> dict:
|
||||
"""Get creation settings schema for a specific Knowledge Engine."""
|
||||
require_workspace_uuid(context)
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
return {}
|
||||
await self.ap.plugin_connector.require_workspace_context(context)
|
||||
try:
|
||||
return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}')
|
||||
return {}
|
||||
|
||||
async def get_engine_retrieval_schema(self, plugin_id: str) -> dict:
|
||||
async def get_engine_retrieval_schema(self, context: TenantContext, plugin_id: str) -> dict:
|
||||
"""Get retrieval settings schema for a specific Knowledge Engine."""
|
||||
require_workspace_uuid(context)
|
||||
if not self.ap.plugin_connector.is_enable_plugin:
|
||||
return {}
|
||||
await self.ap.plugin_connector.require_workspace_context(context)
|
||||
try:
|
||||
return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
|
||||
except Exception as e:
|
||||
|
||||
@@ -11,11 +11,15 @@ import sqlalchemy
|
||||
from ....core import app
|
||||
from ....entity.persistence import bstorage as persistence_bstorage
|
||||
from ....entity.persistence import monitoring as persistence_monitoring
|
||||
from ..authz import WorkspaceRequiredError
|
||||
from ..context import ExecutionContext
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
|
||||
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
|
||||
DEFAULT_LOG_RETENTION_DAYS = 3
|
||||
UPLOAD_OWNER_TYPES = ('upload_image', 'upload_document', 'upload')
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
@@ -26,7 +30,10 @@ class MaintenanceService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def cleanup_expired_files(self) -> dict[str, int]:
|
||||
async def cleanup_expired_files(self, context: ExecutionContext) -> dict[str, int]:
|
||||
if not isinstance(context, ExecutionContext):
|
||||
raise WorkspaceRequiredError('Storage cleanup requires an ExecutionContext')
|
||||
require_workspace_uuid(context)
|
||||
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||
upload_retention_days = self._positive_int(
|
||||
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||
@@ -40,11 +47,14 @@ class MaintenanceService:
|
||||
)
|
||||
|
||||
return {
|
||||
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
|
||||
'log_files': self._cleanup_expired_log_files(log_retention_days),
|
||||
'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days),
|
||||
'log_files': self._cleanup_expired_log_files(log_retention_days)
|
||||
if await self._is_oss_singleton(context)
|
||||
else 0,
|
||||
}
|
||||
|
||||
async def get_storage_analysis(self) -> dict[str, Any]:
|
||||
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
|
||||
require_workspace_uuid(context)
|
||||
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||
upload_retention_days = self._positive_int(
|
||||
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||
@@ -62,15 +72,20 @@ class MaintenanceService:
|
||||
database_path = (
|
||||
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
|
||||
)
|
||||
roots: list[tuple[str, Path | None]] = [
|
||||
('database', database_path),
|
||||
('logs', Path('data/logs')),
|
||||
('storage', Path('data/storage')),
|
||||
('vector_store', Path('data/chroma')),
|
||||
('plugins', Path('data/plugins')),
|
||||
('mcp', Path('data/mcp')),
|
||||
('temp', Path('data/temp')),
|
||||
]
|
||||
is_oss_singleton = await self._is_oss_singleton(context)
|
||||
if is_oss_singleton:
|
||||
roots: list[tuple[str, Path | None]] = [
|
||||
('database', database_path),
|
||||
('logs', Path('data/logs')),
|
||||
('storage', Path('data/storage')),
|
||||
('vector_store', Path('data/chroma')),
|
||||
('plugins', Path('data/plugins')),
|
||||
('mcp', Path('data/mcp')),
|
||||
('temp', Path('data/temp')),
|
||||
]
|
||||
else:
|
||||
scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context)
|
||||
roots = [('storage', scoped_storage_path)]
|
||||
|
||||
sections = []
|
||||
for key, path in roots:
|
||||
@@ -84,10 +99,10 @@ class MaintenanceService:
|
||||
}
|
||||
)
|
||||
|
||||
monitoring_counts = await self._monitoring_counts()
|
||||
binary_storage = await self._binary_storage_stats()
|
||||
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
|
||||
log_candidates = self._expired_log_candidates(log_retention_days)
|
||||
monitoring_counts = await self._monitoring_counts(context)
|
||||
binary_storage = await self._binary_storage_stats(context)
|
||||
upload_candidates = await self._expired_uploaded_candidates(context, upload_retention_days)
|
||||
log_candidates = self._expired_log_candidates(log_retention_days) if is_oss_singleton else []
|
||||
|
||||
return {
|
||||
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
@@ -105,14 +120,32 @@ class MaintenanceService:
|
||||
'uploaded_files': upload_candidates,
|
||||
'log_files': log_candidates,
|
||||
},
|
||||
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
|
||||
'tasks': self.ap.task_mgr.get_stats() if is_oss_singleton and self.ap.task_mgr else {},
|
||||
}
|
||||
|
||||
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
|
||||
async def _is_oss_singleton(self, context: TenantContext) -> bool:
|
||||
try:
|
||||
await self.ap.workspace_service.get_local_execution_binding(
|
||||
require_workspace_uuid(context),
|
||||
expected_generation=getattr(context, 'placement_generation', None),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _cleanup_expired_uploaded_files(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
retention_days: int,
|
||||
) -> int:
|
||||
provider = self.ap.storage_mgr.storage_provider
|
||||
provider_name = provider.__class__.__name__
|
||||
if provider_name == 'LocalStorageProvider':
|
||||
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
|
||||
candidates = self._expired_local_upload_candidates(
|
||||
context,
|
||||
retention_days,
|
||||
include_paths=True,
|
||||
)
|
||||
deleted = 0
|
||||
for item in candidates:
|
||||
try:
|
||||
@@ -125,47 +158,65 @@ class MaintenanceService:
|
||||
return deleted
|
||||
|
||||
if provider_name == 'S3StorageProvider':
|
||||
return await self._cleanup_expired_s3_uploaded_files(retention_days)
|
||||
return await self._cleanup_expired_s3_uploaded_files(context, retention_days)
|
||||
|
||||
return 0
|
||||
|
||||
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||
async def _expired_uploaded_candidates(
|
||||
self,
|
||||
context: TenantContext,
|
||||
retention_days: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
|
||||
if provider_name == 'LocalStorageProvider':
|
||||
return self._expired_local_upload_candidates(retention_days)
|
||||
return self._expired_local_upload_candidates(context, retention_days)
|
||||
if provider_name == 'S3StorageProvider':
|
||||
return await self._expired_s3_upload_candidates(retention_days)
|
||||
return await self._expired_s3_upload_candidates(context, retention_days)
|
||||
return []
|
||||
|
||||
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
|
||||
async def _cleanup_expired_s3_uploaded_files(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
retention_days: int,
|
||||
) -> int:
|
||||
provider = self.ap.storage_mgr.storage_provider
|
||||
candidates = await self._expired_s3_upload_candidates(retention_days)
|
||||
candidates = await self._expired_s3_upload_candidates(context, retention_days)
|
||||
deleted = 0
|
||||
for item in candidates:
|
||||
await provider.delete(item['key'])
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||
async def _expired_s3_upload_candidates(
|
||||
self,
|
||||
context: TenantContext,
|
||||
retention_days: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
provider = self.ap.storage_mgr.storage_provider
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
|
||||
candidates = []
|
||||
paginator = provider.s3_client.get_paginator('list_objects_v2')
|
||||
|
||||
for page in paginator.paginate(Bucket=provider.bucket_name):
|
||||
for obj in page.get('Contents', []):
|
||||
key = obj.get('Key', '')
|
||||
last_modified = obj.get('LastModified')
|
||||
if not self._is_uploaded_file_key(key):
|
||||
continue
|
||||
if last_modified and last_modified < cutoff:
|
||||
candidates.append(
|
||||
{
|
||||
'key': key,
|
||||
'size_bytes': obj.get('Size', 0),
|
||||
'modified_at': last_modified.isoformat(),
|
||||
}
|
||||
)
|
||||
seen_prefixes: set[str] = set()
|
||||
for owner_type in UPLOAD_OWNER_TYPES:
|
||||
prefix = self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type)
|
||||
if prefix in seen_prefixes:
|
||||
continue
|
||||
seen_prefixes.add(prefix)
|
||||
for page in paginator.paginate(Bucket=provider.bucket_name, Prefix=prefix):
|
||||
for obj in page.get('Contents', []):
|
||||
key = obj.get('Key', '')
|
||||
last_modified = obj.get('LastModified')
|
||||
if not self._is_uploaded_file_key(context, key):
|
||||
continue
|
||||
if last_modified and last_modified < cutoff:
|
||||
candidates.append(
|
||||
{
|
||||
'key': key,
|
||||
'size_bytes': obj.get('Size', 0),
|
||||
'modified_at': last_modified.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
@@ -182,28 +233,39 @@ class MaintenanceService:
|
||||
return deleted
|
||||
|
||||
def _expired_local_upload_candidates(
|
||||
self, retention_days: int, include_paths: bool = False
|
||||
self,
|
||||
context: TenantContext,
|
||||
retention_days: int,
|
||||
include_paths: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
storage_root = Path('data/storage')
|
||||
if not storage_root.exists():
|
||||
return []
|
||||
|
||||
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
|
||||
candidates = []
|
||||
for entry in storage_root.iterdir():
|
||||
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
|
||||
seen_roots: set[Path] = set()
|
||||
for owner_type in UPLOAD_OWNER_TYPES:
|
||||
scoped_root = storage_root / self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type)
|
||||
if scoped_root in seen_roots:
|
||||
continue
|
||||
stat = entry.stat()
|
||||
if stat.st_mtime >= cutoff:
|
||||
seen_roots.add(scoped_root)
|
||||
if not scoped_root.exists():
|
||||
continue
|
||||
item = {
|
||||
'key': entry.name,
|
||||
'size_bytes': stat.st_size,
|
||||
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
|
||||
}
|
||||
if include_paths:
|
||||
item['path'] = str(entry)
|
||||
candidates.append(item)
|
||||
for entry in scoped_root.rglob('*'):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
stat = entry.stat()
|
||||
if stat.st_mtime >= cutoff:
|
||||
continue
|
||||
item = {
|
||||
'key': entry.relative_to(storage_root).as_posix(),
|
||||
'size_bytes': stat.st_size,
|
||||
'modified_at': datetime.datetime.fromtimestamp(
|
||||
stat.st_mtime,
|
||||
datetime.timezone.utc,
|
||||
).isoformat(),
|
||||
}
|
||||
if include_paths:
|
||||
item['path'] = str(entry)
|
||||
candidates.append(item)
|
||||
return candidates
|
||||
|
||||
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
|
||||
@@ -236,33 +298,51 @@ class MaintenanceService:
|
||||
candidates.append(item)
|
||||
return candidates
|
||||
|
||||
def _is_uploaded_file_key(self, key: str) -> bool:
|
||||
return '/' not in key and not key.startswith('plugin_config_')
|
||||
def _is_uploaded_file_key(self, context: TenantContext, key: str) -> bool:
|
||||
return any(
|
||||
key.startswith(self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type))
|
||||
and self.ap.storage_mgr.is_scoped_object_key(key, expected_owner_type=owner_type)
|
||||
for owner_type in UPLOAD_OWNER_TYPES
|
||||
)
|
||||
|
||||
async def _monitoring_counts(self) -> dict[str, int]:
|
||||
async def _monitoring_counts(self, context: TenantContext) -> dict[str, int]:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
tables = {
|
||||
'messages': persistence_monitoring.MonitoringMessage.id,
|
||||
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
||||
'tool_calls': persistence_monitoring.MonitoringToolCall.id,
|
||||
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||
'errors': persistence_monitoring.MonitoringError.id,
|
||||
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
||||
'feedback': persistence_monitoring.MonitoringFeedback.id,
|
||||
'messages': (persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage.id),
|
||||
'llm_calls': (persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall.id),
|
||||
'tool_calls': (persistence_monitoring.MonitoringToolCall, persistence_monitoring.MonitoringToolCall.id),
|
||||
'embedding_calls': (
|
||||
persistence_monitoring.MonitoringEmbeddingCall,
|
||||
persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||
),
|
||||
'errors': (persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError.id),
|
||||
'sessions': (
|
||||
persistence_monitoring.MonitoringSession,
|
||||
persistence_monitoring.MonitoringSession.session_id,
|
||||
),
|
||||
'feedback': (persistence_monitoring.MonitoringFeedback, persistence_monitoring.MonitoringFeedback.id),
|
||||
}
|
||||
counts: dict[str, int] = {}
|
||||
for key, column in tables.items():
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
|
||||
for key, (model, column) in tables.items():
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count(column)).where(model.workspace_uuid == workspace_uuid)
|
||||
)
|
||||
counts[key] = result.scalar() or 0
|
||||
return counts
|
||||
|
||||
async def _binary_storage_stats(self) -> dict[str, Any]:
|
||||
async def _binary_storage_stats(self, context: TenantContext) -> dict[str, Any]:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
count_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
|
||||
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key)).where(
|
||||
persistence_bstorage.BinaryStorage.workspace_uuid == workspace_uuid
|
||||
)
|
||||
)
|
||||
size_bytes = None
|
||||
try:
|
||||
size_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
|
||||
sqlalchemy.select(
|
||||
sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value))
|
||||
).where(persistence_bstorage.BinaryStorage.workspace_uuid == workspace_uuid)
|
||||
)
|
||||
size_bytes = size_result.scalar() or 0
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,198 +1,417 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
import asyncio
|
||||
import copy
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from ....core import app
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app, taskmgr
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from ....core import taskmgr
|
||||
from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus
|
||||
from ....entity.persistence import plugin as persistence_plugin
|
||||
from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ..context import ExecutionContext
|
||||
from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
|
||||
_SECRET_MASK = '***'
|
||||
_MISSING_SECRET = object()
|
||||
_SENSITIVE_CONFIG_NAMES = frozenset(
|
||||
{
|
||||
'api_key',
|
||||
'apikey',
|
||||
'auth',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'credentials',
|
||||
'database_url',
|
||||
'dsn',
|
||||
'key',
|
||||
'proxy_authorization',
|
||||
'set_cookie',
|
||||
}
|
||||
)
|
||||
_SENSITIVE_CONFIG_TOKENS = frozenset(
|
||||
{
|
||||
'credential',
|
||||
'credentials',
|
||||
'passwd',
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
}
|
||||
)
|
||||
_SENSITIVE_KEY_QUALIFIERS = frozenset(
|
||||
{
|
||||
'access',
|
||||
'api',
|
||||
'auth',
|
||||
'bearer',
|
||||
'client',
|
||||
'debug',
|
||||
'encryption',
|
||||
'private',
|
||||
'signing',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_config_key(key: object) -> str:
|
||||
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
|
||||
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
|
||||
|
||||
|
||||
def _is_sensitive_config_key(key: object) -> bool:
|
||||
normalized = _normalize_config_key(key)
|
||||
if normalized in _SENSITIVE_CONFIG_NAMES:
|
||||
return True
|
||||
tokens = frozenset(token for token in normalized.split('_') if token)
|
||||
if tokens & _SENSITIVE_CONFIG_TOKENS:
|
||||
return True
|
||||
return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
|
||||
|
||||
|
||||
def _mask_secret_structure(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _mask_secret_structure(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_mask_secret_structure(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_mask_secret_structure(item) for item in value)
|
||||
if value is None or value == '':
|
||||
return value
|
||||
return _SECRET_MASK
|
||||
|
||||
|
||||
def redact_mcp_secrets(value):
|
||||
"""Return a recursively redacted copy of MCP configuration data."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (
|
||||
_mask_secret_structure(item)
|
||||
if _is_sensitive_config_key(key)
|
||||
else redact_url_secrets(item)
|
||||
if is_url_key(key)
|
||||
else redact_mcp_secrets(item)
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_mcp_secrets(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_mcp_secrets(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def restore_mcp_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
|
||||
"""Restore masked leaves from the current MCP config before a write."""
|
||||
|
||||
if sensitive and value == _SECRET_MASK:
|
||||
if current_value is _MISSING_SECRET:
|
||||
raise ValueError('Masked MCP secret has no existing value')
|
||||
return copy.deepcopy(current_value)
|
||||
if isinstance(value, dict):
|
||||
current_mapping = current_value if isinstance(current_value, dict) else {}
|
||||
return {
|
||||
key: (
|
||||
restore_url_secret_placeholders(
|
||||
item,
|
||||
current_mapping.get(key, _MISSING_SECRET),
|
||||
)
|
||||
if not sensitive and not _is_sensitive_config_key(key) and is_url_key(key)
|
||||
else restore_mcp_secret_placeholders(
|
||||
item,
|
||||
current_mapping.get(key, _MISSING_SECRET),
|
||||
sensitive=sensitive or _is_sensitive_config_key(key),
|
||||
)
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return [
|
||||
restore_mcp_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return tuple(
|
||||
restore_mcp_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class MCPService:
|
||||
"""Workspace-scoped MCP configuration and runtime facade."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_runtime_info(self, server_name: str) -> dict | None:
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
if session:
|
||||
return session.get_runtime_info_dict()
|
||||
return None
|
||||
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid or not isinstance(generation, int) or isinstance(generation, bool) or generation <= 0:
|
||||
raise ValueError('MCP operations require an explicit fenced execution context')
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
workspace_uuid,
|
||||
expected_generation=generation,
|
||||
)
|
||||
if binding.instance_uuid != instance_uuid:
|
||||
raise ValueError('MCP execution context belongs to another LangBot instance')
|
||||
return ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
)
|
||||
|
||||
async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
|
||||
async def get_runtime_info(self, context: TenantContext, server_name: str) -> dict | None:
|
||||
execution_context = await self._execution_context(context)
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||
return session.get_runtime_info_dict() if session else None
|
||||
|
||||
servers = result.all()
|
||||
async def get_mcp_servers(self, context: TenantContext, contain_runtime_info: bool = False) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(sqlalchemy.select(persistence_mcp.MCPServer), persistence_mcp.MCPServer, context)
|
||||
)
|
||||
serialized_servers = [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers
|
||||
redact_mcp_secrets(self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server))
|
||||
for server in result.all()
|
||||
]
|
||||
if contain_runtime_info:
|
||||
for server in serialized_servers:
|
||||
runtime_info = await self.get_runtime_info(server['name'])
|
||||
|
||||
server['runtime_info'] = runtime_info if runtime_info else None
|
||||
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server['name'])
|
||||
server['runtime_info'] = session.get_runtime_info_dict() if session else None
|
||||
return serialized_servers
|
||||
|
||||
async def create_mcp_server(self, server_data: dict) -> str:
|
||||
# Check limitation (extensions = MCP servers + plugins)
|
||||
async def create_mcp_server(self, context: TenantContext, server_data: dict) -> str:
|
||||
execution_context = await self._execution_context(context)
|
||||
workspace_uuid = execution_context.workspace_uuid
|
||||
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_extensions = limitation.get('max_extensions', -1)
|
||||
if max_extensions >= 0:
|
||||
existing_mcp_servers = await self.get_mcp_servers()
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
total_extensions = len(existing_mcp_servers) + len(plugins)
|
||||
if total_extensions >= max_extensions:
|
||||
mcp_count_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count(persistence_mcp.MCPServer.uuid)).where(
|
||||
persistence_mcp.MCPServer.workspace_uuid == workspace_uuid
|
||||
)
|
||||
)
|
||||
plugin_count_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_plugin.PluginSetting)
|
||||
.where(persistence_plugin.PluginSetting.workspace_uuid == workspace_uuid)
|
||||
)
|
||||
if (mcp_count_result.scalar() or 0) + (plugin_count_result.scalar() or 0) >= max_extensions:
|
||||
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
|
||||
|
||||
server_name = str(server_data.get('name') or '').strip()
|
||||
payload = dict(server_data)
|
||||
payload.pop('workspace_uuid', None)
|
||||
server_name = str(payload.get('name') or '').strip()
|
||||
if not server_name:
|
||||
raise ValueError('MCP server name is required')
|
||||
server_data['name'] = server_name
|
||||
payload['name'] = server_name
|
||||
payload['workspace_uuid'] = workspace_uuid
|
||||
payload['uuid'] = str(uuid.uuid4())
|
||||
|
||||
existing_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(
|
||||
persistence_mcp.MCPServer.workspace_uuid == workspace_uuid,
|
||||
persistence_mcp.MCPServer.name == server_name,
|
||||
)
|
||||
)
|
||||
if existing_result.first() is not None:
|
||||
raise ValueError(f'MCP server already exists: {server_name}')
|
||||
|
||||
server_data['uuid'] = str(uuid.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(payload))
|
||||
created = await self._get_mcp_server_by_uuid_raw(execution_context, payload['uuid'])
|
||||
if created and self.ap.tool_mgr.mcp_tool_loader:
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(execution_context, created))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
return payload['uuid']
|
||||
|
||||
async def get_mcp_server_by_uuid(self, context: TenantContext, server_uuid: str) -> dict | None:
|
||||
execution_context = await self._execution_context(context)
|
||||
server_data = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
|
||||
return redact_mcp_secrets(server_data) if server_data is not None else None
|
||||
|
||||
async def _get_mcp_server_by_uuid_raw(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
server_uuid: str,
|
||||
) -> dict | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid'])
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid),
|
||||
persistence_mcp.MCPServer,
|
||||
execution_context,
|
||||
)
|
||||
)
|
||||
server_entity = result.first()
|
||||
if server_entity:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
|
||||
if self.ap.tool_mgr.mcp_tool_loader:
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
server = result.first()
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) if server else None
|
||||
|
||||
return server_data['uuid']
|
||||
async def get_mcp_server_by_name(self, context: TenantContext, server_name: str) -> dict | None:
|
||||
execution_context = await self._execution_context(context)
|
||||
server_data = await self._get_mcp_server_by_name_raw(execution_context, server_name)
|
||||
if server_data is None:
|
||||
return None
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||
response_data = {
|
||||
**server_data,
|
||||
'runtime_info': session.get_runtime_info_dict() if session else None,
|
||||
}
|
||||
return redact_mcp_secrets(response_data)
|
||||
|
||||
async def get_mcp_server_by_name(self, server_name: str) -> dict | None:
|
||||
async def _get_mcp_server_by_name_raw(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
server_name: str,
|
||||
) -> dict | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name),
|
||||
persistence_mcp.MCPServer,
|
||||
execution_context,
|
||||
)
|
||||
)
|
||||
server = result.first()
|
||||
if server is None:
|
||||
return None
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
|
||||
runtime_info = await self.get_runtime_info(server.name)
|
||||
server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
server_data['runtime_info'] = runtime_info if runtime_info else None
|
||||
return server_data
|
||||
async def update_mcp_server(self, context: TenantContext, server_uuid: str, server_data: dict) -> None:
|
||||
execution_context = await self._execution_context(context)
|
||||
old_server = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
|
||||
if old_server is None:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
|
||||
payload = dict(server_data)
|
||||
payload.pop('uuid', None)
|
||||
payload.pop('workspace_uuid', None)
|
||||
payload = restore_mcp_secret_placeholders(payload, old_server)
|
||||
if 'name' in payload:
|
||||
payload['name'] = str(payload['name'] or '').strip()
|
||||
if not payload['name']:
|
||||
raise ValueError('MCP server name is required')
|
||||
duplicate = await self._get_mcp_server_by_name_raw(execution_context, payload['name'])
|
||||
if duplicate is not None and duplicate['uuid'] != server_uuid:
|
||||
raise ValueError(f'MCP server already exists: {payload["name"]}')
|
||||
|
||||
async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_mcp.MCPServer)
|
||||
.where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
.values(payload),
|
||||
persistence_mcp.MCPServer,
|
||||
execution_context,
|
||||
)
|
||||
)
|
||||
old_server = result.first()
|
||||
old_server_name = old_server.name if old_server else None
|
||||
old_enable = old_server.enable if old_server else False
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_mcp.MCPServer)
|
||||
.where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
.values(server_data)
|
||||
)
|
||||
loader = self.ap.tool_mgr.mcp_tool_loader
|
||||
if loader is None:
|
||||
return
|
||||
old_name = old_server['name']
|
||||
old_enable = bool(old_server['enable'])
|
||||
updated = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
|
||||
if updated is None:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
new_enable = bool(updated['enable'])
|
||||
if old_enable and loader.has_session(execution_context, old_name):
|
||||
await loader.remove_mcp_server(execution_context, old_name)
|
||||
if new_enable:
|
||||
task = asyncio.create_task(loader.host_mcp_server(execution_context, updated))
|
||||
loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
if self.ap.tool_mgr.mcp_tool_loader:
|
||||
new_enable = server_data.get('enable', False)
|
||||
|
||||
need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions
|
||||
|
||||
if old_enable and not new_enable:
|
||||
if need_remove:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
|
||||
|
||||
elif not old_enable and new_enable:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
updated_server = result.first()
|
||||
if updated_server:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
elif old_enable and new_enable:
|
||||
if need_remove:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
updated_server = result.first()
|
||||
if updated_server:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
|
||||
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
|
||||
|
||||
async def delete_mcp_server(self, server_uuid: str) -> None:
|
||||
async def delete_mcp_server(self, context: TenantContext, server_uuid: str) -> None:
|
||||
execution_context = await self._execution_context(context)
|
||||
server = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
|
||||
if server is None:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid),
|
||||
persistence_mcp.MCPServer,
|
||||
execution_context,
|
||||
)
|
||||
)
|
||||
server = result.first()
|
||||
server_name = server.name if server else None
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
loader = self.ap.tool_mgr.mcp_tool_loader
|
||||
if loader and loader.has_session(execution_context, server['name']):
|
||||
await loader.remove_mcp_server(execution_context, server['name'])
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
async def _require_server(self, context: TenantContext, server_name: str) -> tuple[ExecutionContext, dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
server = await self._get_mcp_server_by_name_raw(execution_context, server_name)
|
||||
if server is None:
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
return execution_context, server
|
||||
|
||||
if server_name and self.ap.tool_mgr.mcp_tool_loader:
|
||||
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
|
||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||
async def get_mcp_server_resources(self, context: TenantContext, server_name: str) -> list[dict]:
|
||||
execution_context, _ = await self._require_server(context, server_name)
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(execution_context, server_name)
|
||||
|
||||
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
|
||||
"""Get resources from a specific MCP server."""
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
|
||||
|
||||
async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
|
||||
"""Get resource templates from a specific MCP server."""
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
|
||||
async def get_mcp_server_resource_templates(self, context: TenantContext, server_name: str) -> list[dict]:
|
||||
execution_context, _ = await self._require_server(context, server_name)
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(execution_context, server_name)
|
||||
|
||||
async def read_mcp_server_resource_envelope(
|
||||
self,
|
||||
context: TenantContext,
|
||||
server_name: str,
|
||||
uri: str,
|
||||
*,
|
||||
max_bytes: int | None = None,
|
||||
include_blob: bool = False,
|
||||
) -> dict:
|
||||
"""Read a resource from a specific MCP server with metadata."""
|
||||
execution_context, _ = await self._require_server(context, server_name)
|
||||
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
|
||||
if max_bytes is not None:
|
||||
kwargs['max_bytes'] = max_bytes
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(
|
||||
execution_context,
|
||||
server_name,
|
||||
uri,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
|
||||
"""Read a resource from a specific MCP server."""
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
|
||||
|
||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||
async def read_mcp_server_resource(self, context: TenantContext, server_name: str, uri: str) -> list[dict]:
|
||||
execution_context, _ = await self._require_server(context, server_name)
|
||||
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(execution_context, server_name, uri)
|
||||
|
||||
async def test_mcp_server(self, context: TenantContext, server_name: str, server_data: dict) -> int:
|
||||
execution_context = await self._execution_context(context)
|
||||
runtime_mcp_session: RuntimeMCPSession | None = None
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
|
||||
if server_name != '_':
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
await self._require_server(execution_context, server_name)
|
||||
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||
if runtime_mcp_session is None:
|
||||
raise ValueError(f'Server not found: {server_name}')
|
||||
|
||||
raise WorkspaceNotFoundError('MCP server not found')
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
async def _refresh_and_report() -> None:
|
||||
# Testing a persisted server should REUSE its live shared-session
|
||||
# process, not rebuild it. Try a lightweight refresh (a real
|
||||
# list_tools probe over the existing connection) first; only fall
|
||||
# back to a full start() when the session has no live connection
|
||||
# to probe (never connected, or the process is actually gone).
|
||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
@@ -200,30 +419,23 @@ class MCPService:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
# The live connection was stale/dropped: reconnect once
|
||||
# (reusing the live managed process where possible) and
|
||||
# re-probe, instead of reporting a false failure.
|
||||
await persisted_session.start()
|
||||
# Surface the discovered tools so the config page can render them
|
||||
# even for an already-hosted server.
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
|
||||
coroutine = _refresh_and_report()
|
||||
else:
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
|
||||
|
||||
# A transient test owns an isolated Box session. Always tear it down
|
||||
# after the test completes (success or failure) so it does not leak.
|
||||
payload = dict(server_data)
|
||||
payload.pop('workspace_uuid', None)
|
||||
payload['workspace_uuid'] = execution_context.workspace_uuid
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
|
||||
execution_context,
|
||||
payload,
|
||||
)
|
||||
test_session = runtime_mcp_session
|
||||
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
# Capture the runtime info (status + discovered tools) BEFORE
|
||||
# shutting the transient session down. The create/edit config
|
||||
# page has no persisted server to reload from, so without this
|
||||
# a successful test could only show "no tools found". The
|
||||
# frontend reads ctx.metadata.runtime_info to render the tools.
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
finally:
|
||||
try:
|
||||
@@ -239,24 +451,27 @@ class MCPService:
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
coroutine,
|
||||
kind='mcp-operation',
|
||||
name=f'mcp-test-{server_name}',
|
||||
name=f'mcp-test-{execution_context.workspace_uuid}-{server_name}',
|
||||
label=f'Testing MCP server {server_name}',
|
||||
context=ctx,
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
)
|
||||
return wrapper.id
|
||||
|
||||
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
|
||||
"""Get recent log lines captured from the MCP server's stderr."""
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
||||
async def get_mcp_server_logs(
|
||||
self,
|
||||
context: TenantContext,
|
||||
server_name: str,
|
||||
limit: int = 200,
|
||||
level: str | None = None,
|
||||
) -> list[dict]:
|
||||
execution_context, _ = await self._require_server(context, server_name)
|
||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
|
||||
if not session:
|
||||
return []
|
||||
|
||||
# Get logs from the session's buffer
|
||||
logs = list(session._log_buffer)
|
||||
|
||||
# Filter by level if specified
|
||||
if level:
|
||||
logs = [log for log in logs if log.get('level') == level]
|
||||
|
||||
# Return the most recent 'limit' logs
|
||||
return logs[-limit:]
|
||||
|
||||
@@ -9,6 +9,9 @@ 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 ....workspace.errors import WorkspaceNotFoundError
|
||||
from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
|
||||
def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
||||
@@ -34,7 +37,29 @@ def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
|
||||
return {**model_data, 'uuid': model_uuid}
|
||||
|
||||
|
||||
async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None:
|
||||
def _redact_model_secrets(model_data: dict) -> dict:
|
||||
"""Return a copy with model args and embedded provider credentials masked."""
|
||||
|
||||
redacted = model_data.copy()
|
||||
if 'extra_args' in redacted:
|
||||
redacted['extra_args'] = redact_secrets(redacted['extra_args'])
|
||||
if isinstance(redacted.get('provider'), dict):
|
||||
provider = redacted['provider'].copy()
|
||||
# ModelProvider never contains another provider. Dropping this key also
|
||||
# makes the serializer robust to a reused/self-referential test double.
|
||||
provider.pop('provider', None)
|
||||
if 'api_keys' in provider:
|
||||
provider['api_keys'] = mask_secret_value(provider['api_keys'])
|
||||
redacted['provider'] = provider
|
||||
return redacted
|
||||
|
||||
|
||||
async def _validate_provider_supports(
|
||||
ap: app.Application,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
model_type: str,
|
||||
) -> None:
|
||||
"""Validate that the provider's requester declares support for ``model_type``.
|
||||
|
||||
``model_type`` is one of the manifest ``support_type`` values:
|
||||
@@ -47,11 +72,12 @@ async def _validate_provider_supports(ap: app.Application, provider_uuid: str, m
|
||||
if model_mgr is None:
|
||||
return
|
||||
|
||||
provider_dict = getattr(model_mgr, 'provider_dict', None)
|
||||
if not provider_dict:
|
||||
get_provider = getattr(model_mgr, 'get_provider_by_uuid', None)
|
||||
if not callable(get_provider):
|
||||
return
|
||||
runtime_provider = provider_dict.get(provider_uuid)
|
||||
if runtime_provider is None:
|
||||
try:
|
||||
runtime_provider = await get_provider(context, provider_uuid)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None)
|
||||
@@ -74,20 +100,48 @@ async def _validate_provider_supports(ap: app.Application, provider_uuid: str, m
|
||||
raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models')
|
||||
|
||||
|
||||
async def _require_workspace_provider(
|
||||
ap: app.Application,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
) -> dict:
|
||||
"""Require the referenced provider to belong to the active Workspace."""
|
||||
|
||||
provider = await ap.provider_service.get_provider(context, provider_uuid)
|
||||
if provider is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
return provider
|
||||
|
||||
|
||||
async def _require_runtime_provider(
|
||||
ap: app.Application,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
) -> model_requester.RuntimeProvider:
|
||||
try:
|
||||
return await ap.model_mgr.get_provider_by_uuid(context, provider_uuid)
|
||||
except ValueError as exc:
|
||||
raise Exception('provider not found') from exc
|
||||
|
||||
|
||||
class LLMModelsService:
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
|
||||
async def get_llm_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
||||
"""Get all LLM models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(sqlalchemy.select(persistence_model.LLMModel), persistence_model.LLMModel, context)
|
||||
)
|
||||
models = result.all()
|
||||
|
||||
# Get all providers for lookup
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
|
||||
)
|
||||
)
|
||||
providers = {p.uuid: p for p in providers_result.all()}
|
||||
|
||||
@@ -98,29 +152,50 @@ class LLMModelsService:
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
if not include_secret:
|
||||
provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))
|
||||
model_dict['provider'] = provider_dict
|
||||
if not include_secret:
|
||||
model_dict = _redact_model_secrets(model_dict)
|
||||
models_list.append(model_dict)
|
||||
|
||||
return models_list
|
||||
|
||||
async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||
async def get_llm_models_by_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get LLM models by provider UUID"""
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.LLMModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
|
||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
|
||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||
|
||||
async def create_llm_model(
|
||||
self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_data: dict,
|
||||
preserve_uuid: bool = False,
|
||||
auto_set_to_default_pipeline: bool = True,
|
||||
) -> str:
|
||||
"""Create a new LLM model"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
model_data = model_data.copy()
|
||||
if not preserve_uuid:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
model_data['workspace_uuid'] = workspace_uuid
|
||||
if 'extra_args' in model_data:
|
||||
model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
|
||||
|
||||
# Handle provider creation if needed
|
||||
if 'provider' in model_data:
|
||||
@@ -130,31 +205,35 @@ class LLMModelsService:
|
||||
else:
|
||||
# Create new provider
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
context,
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm')
|
||||
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
|
||||
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
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),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
||||
|
||||
if auto_set_to_default_pipeline:
|
||||
# set the default pipeline model to this model
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.is_default == True
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.is_default == True
|
||||
),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
pipeline = result.first()
|
||||
@@ -167,14 +246,23 @@ class LLMModelsService:
|
||||
'fallbacks': [],
|
||||
}
|
||||
pipeline_data = {'config': pipeline_config}
|
||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||
await self.ap.pipeline_service.update_pipeline(context, pipeline.uuid, pipeline_data)
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
async def get_llm_model(self, model_uuid: str) -> dict | None:
|
||||
async def get_llm_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
"""Get a single LLM model with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
||||
persistence_model.LLMModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
model = result.first()
|
||||
if model is None:
|
||||
@@ -184,21 +272,38 @@ class LLMModelsService:
|
||||
|
||||
# Get provider
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
provider = provider_result.first()
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
model_dict['provider'] = provider_dict
|
||||
|
||||
if not include_secret:
|
||||
model_dict = _redact_model_secrets(model_dict)
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_llm_model(self, 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"""
|
||||
if 'uuid' in model_data:
|
||||
del model_data['uuid']
|
||||
existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
model_data = model_data.copy()
|
||||
model_data.pop('uuid', None)
|
||||
model_data.pop('workspace_uuid', None)
|
||||
if 'extra_args' in model_data:
|
||||
model_data['extra_args'] = restore_secret_placeholders(
|
||||
model_data['extra_args'],
|
||||
existing_model.get('extra_args', {}),
|
||||
)
|
||||
|
||||
# Handle provider update if needed
|
||||
if 'provider' in model_data:
|
||||
@@ -207,50 +312,71 @@ class LLMModelsService:
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
context,
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
.values(**model_data)
|
||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
.values(**model_data),
|
||||
persistence_model.LLMModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
|
||||
await self.ap.model_mgr.remove_llm_model(model_uuid)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider 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(
|
||||
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
|
||||
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'}
|
||||
},
|
||||
)
|
||||
),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
|
||||
|
||||
async def delete_llm_model(self, model_uuid: str) -> None:
|
||||
async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
"""Delete an LLM model"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
|
||||
persistence_model.LLMModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
await self.ap.model_mgr.remove_llm_model(model_uuid)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
|
||||
|
||||
async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
async def test_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||
"""Test an LLM model"""
|
||||
require_workspace_uuid(context)
|
||||
runtime_llm_model: model_requester.RuntimeLLMModel | None = None
|
||||
|
||||
if model_uuid != '_':
|
||||
for model in self.ap.model_mgr.llm_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
runtime_llm_model = model
|
||||
break
|
||||
if runtime_llm_model is None:
|
||||
raise Exception('model not found')
|
||||
if await self.get_llm_model(context, model_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
|
||||
else:
|
||||
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(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', {})
|
||||
await runtime_llm_model.provider.invoke_llm(
|
||||
@@ -259,6 +385,7 @@ class LLMModelsService:
|
||||
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
|
||||
funcs=[],
|
||||
extra_args=extra_args,
|
||||
execution_context=runtime_llm_model.execution_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -268,13 +395,19 @@ class EmbeddingModelsService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_embedding_models(self) -> list[dict]:
|
||||
async def get_embedding_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
||||
"""Get all embedding models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel), persistence_model.EmbeddingModel, context
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
|
||||
)
|
||||
)
|
||||
providers = {p.uuid: p for p in providers_result.all()}
|
||||
|
||||
@@ -284,25 +417,46 @@ class EmbeddingModelsService:
|
||||
provider = providers.get(model.provider_uuid)
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
model_dict['provider'] = provider_dict
|
||||
if not include_secret:
|
||||
model_dict = _redact_model_secrets(model_dict)
|
||||
models_list.append(model_dict)
|
||||
|
||||
return models_list
|
||||
|
||||
async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||
async def get_embedding_models_by_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get embedding models by provider UUID"""
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.EmbeddingModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
|
||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
|
||||
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
|
||||
|
||||
async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
|
||||
async def create_embedding_model(
|
||||
self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
|
||||
) -> str:
|
||||
"""Create a new embedding model"""
|
||||
model_data = model_data.copy()
|
||||
if not preserve_uuid:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
model_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||
if 'extra_args' in model_data:
|
||||
model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
@@ -310,35 +464,44 @@ class EmbeddingModelsService:
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
context,
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding')
|
||||
await _require_workspace_provider(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(
|
||||
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
|
||||
)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
|
||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||
context,
|
||||
persistence_model.EmbeddingModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||
await self.ap.model_mgr.cache_embedding_model(context, runtime_embedding_model)
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
async def get_embedding_model(self, model_uuid: str) -> dict | None:
|
||||
async def get_embedding_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
"""Get a single embedding model with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.uuid == model_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.uuid == model_uuid
|
||||
),
|
||||
persistence_model.EmbeddingModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
model = result.first()
|
||||
@@ -348,21 +511,38 @@ class EmbeddingModelsService:
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
|
||||
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
provider = provider_result.first()
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
model_dict['provider'] = provider_dict
|
||||
|
||||
if not include_secret:
|
||||
model_dict = _redact_model_secrets(model_dict)
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_embedding_model(self, 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"""
|
||||
if 'uuid' in model_data:
|
||||
del model_data['uuid']
|
||||
existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
model_data = model_data.copy()
|
||||
model_data.pop('uuid', None)
|
||||
model_data.pop('workspace_uuid', None)
|
||||
if 'extra_args' in model_data:
|
||||
model_data['extra_args'] = restore_secret_placeholders(
|
||||
model_data['extra_args'],
|
||||
existing_model.get('extra_args', {}),
|
||||
)
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
@@ -370,57 +550,82 @@ class EmbeddingModelsService:
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
context,
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.uuid == model_uuid)
|
||||
.values(**model_data)
|
||||
)
|
||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
|
||||
|
||||
await self.ap.model_mgr.remove_embedding_model(model_uuid)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||
|
||||
async def delete_embedding_model(self, model_uuid: str) -> None:
|
||||
"""Delete an embedding model"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.uuid == model_uuid
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.uuid == model_uuid)
|
||||
.values(**model_data),
|
||||
persistence_model.EmbeddingModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
await self.ap.model_mgr.remove_embedding_model(model_uuid)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
|
||||
async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
await self.ap.model_mgr.remove_embedding_model(context, model_uuid)
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
|
||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||
context,
|
||||
persistence_model.EmbeddingModel(
|
||||
**_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'}
|
||||
},
|
||||
)
|
||||
),
|
||||
runtime_provider,
|
||||
)
|
||||
await self.ap.model_mgr.cache_embedding_model(context, runtime_embedding_model)
|
||||
|
||||
async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
"""Delete an embedding model"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.uuid == model_uuid
|
||||
),
|
||||
persistence_model.EmbeddingModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await self.ap.model_mgr.remove_embedding_model(context, model_uuid)
|
||||
|
||||
async def test_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||
"""Test an embedding model"""
|
||||
require_workspace_uuid(context)
|
||||
runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None
|
||||
|
||||
if model_uuid != '_':
|
||||
for model in self.ap.model_mgr.embedding_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
runtime_embedding_model = model
|
||||
break
|
||||
if runtime_embedding_model is None:
|
||||
raise Exception('model not found')
|
||||
if await self.get_embedding_model(context, model_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
runtime_embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(context, model_uuid)
|
||||
else:
|
||||
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
|
||||
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(
|
||||
context,
|
||||
model_data,
|
||||
)
|
||||
|
||||
await runtime_embedding_model.provider.invoke_embedding(
|
||||
model=runtime_embedding_model,
|
||||
input_text=['Hello, world!'],
|
||||
extra_args={},
|
||||
execution_context=runtime_embedding_model.execution_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -430,13 +635,17 @@ class RerankModelsService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_rerank_models(self) -> list[dict]:
|
||||
async def get_rerank_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
||||
"""Get all rerank models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(sqlalchemy.select(persistence_model.RerankModel), persistence_model.RerankModel, context)
|
||||
)
|
||||
models = result.all()
|
||||
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
|
||||
)
|
||||
)
|
||||
providers = {p.uuid: p for p in providers_result.all()}
|
||||
|
||||
@@ -446,25 +655,44 @@ class RerankModelsService:
|
||||
provider = providers.get(model.provider_uuid)
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
model_dict['provider'] = provider_dict
|
||||
if not include_secret:
|
||||
model_dict = _redact_model_secrets(model_dict)
|
||||
models_list.append(model_dict)
|
||||
|
||||
return models_list
|
||||
|
||||
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
|
||||
async def get_rerank_models_by_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get rerank models by provider UUID"""
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.RerankModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
models = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
|
||||
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, 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:
|
||||
model_data['uuid'] = str(uuid.uuid4())
|
||||
model_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||
if 'extra_args' in model_data:
|
||||
model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
@@ -472,34 +700,45 @@ class RerankModelsService:
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
context,
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank')
|
||||
await _require_workspace_provider(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(
|
||||
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
|
||||
)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
|
||||
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||
context,
|
||||
persistence_model.RerankModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||
await self.ap.model_mgr.cache_rerank_model(context, runtime_rerank_model)
|
||||
|
||||
return model_data['uuid']
|
||||
|
||||
async def get_rerank_model(self, model_uuid: str) -> dict | None:
|
||||
async def get_rerank_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
"""Get a single rerank model with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.uuid == model_uuid
|
||||
),
|
||||
persistence_model.RerankModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
model = result.first()
|
||||
if model is None:
|
||||
@@ -508,21 +747,38 @@ class RerankModelsService:
|
||||
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
|
||||
|
||||
provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == model.provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
provider = provider_result.first()
|
||||
if provider:
|
||||
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
|
||||
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
|
||||
provider_dict = _parse_provider_api_keys(provider_dict)
|
||||
model_dict['provider'] = provider_dict
|
||||
|
||||
if not include_secret:
|
||||
model_dict = _redact_model_secrets(model_dict)
|
||||
|
||||
return model_dict
|
||||
|
||||
async def update_rerank_model(self, 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"""
|
||||
if 'uuid' in model_data:
|
||||
del model_data['uuid']
|
||||
existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
|
||||
if existing_model is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
model_data = model_data.copy()
|
||||
model_data.pop('uuid', None)
|
||||
model_data.pop('workspace_uuid', None)
|
||||
if 'extra_args' in model_data:
|
||||
model_data['extra_args'] = restore_secret_placeholders(
|
||||
model_data['extra_args'],
|
||||
existing_model.get('extra_args', {}),
|
||||
)
|
||||
|
||||
if 'provider' in model_data:
|
||||
provider_data = model_data.pop('provider')
|
||||
@@ -530,50 +786,76 @@ class RerankModelsService:
|
||||
model_data['provider_uuid'] = provider_data['uuid']
|
||||
else:
|
||||
provider_uuid = await self.ap.provider_service.find_or_create_provider(
|
||||
context,
|
||||
requester=provider_data.get('requester', ''),
|
||||
base_url=provider_data.get('base_url', ''),
|
||||
api_keys=provider_data.get('api_keys', []),
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.RerankModel)
|
||||
.where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
.values(**model_data)
|
||||
provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
|
||||
await _require_workspace_provider(self.ap, context, provider_uuid)
|
||||
await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_model.RerankModel)
|
||||
.where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
.values(**model_data),
|
||||
persistence_model.RerankModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
|
||||
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
if runtime_provider is None:
|
||||
raise Exception('provider not found')
|
||||
|
||||
await self.ap.model_mgr.remove_rerank_model(context, model_uuid)
|
||||
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
|
||||
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
|
||||
context,
|
||||
persistence_model.RerankModel(
|
||||
**_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'}
|
||||
},
|
||||
)
|
||||
),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||
await self.ap.model_mgr.cache_rerank_model(context, runtime_rerank_model)
|
||||
|
||||
async def delete_rerank_model(self, model_uuid: str) -> None:
|
||||
async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
"""Delete a rerank model"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.uuid == model_uuid
|
||||
),
|
||||
persistence_model.RerankModel,
|
||||
context,
|
||||
)
|
||||
)
|
||||
await self.ap.model_mgr.remove_rerank_model(model_uuid)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
await self.ap.model_mgr.remove_rerank_model(context, model_uuid)
|
||||
|
||||
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
|
||||
async def test_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
|
||||
"""Test a rerank model"""
|
||||
require_workspace_uuid(context)
|
||||
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
|
||||
|
||||
if model_uuid != '_':
|
||||
for model in self.ap.model_mgr.rerank_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
runtime_rerank_model = model
|
||||
break
|
||||
if runtime_rerank_model is None:
|
||||
raise Exception('model not found')
|
||||
if await self.get_rerank_model(context, model_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Model not found')
|
||||
runtime_rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(context, model_uuid)
|
||||
else:
|
||||
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
|
||||
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(
|
||||
context,
|
||||
model_data,
|
||||
)
|
||||
|
||||
await runtime_rerank_model.provider.invoke_rerank(
|
||||
model=runtime_rerank_model,
|
||||
@@ -582,4 +864,5 @@ class RerankModelsService:
|
||||
'Artificial intelligence is a branch of computer science.',
|
||||
'The weather is nice today.',
|
||||
],
|
||||
execution_context=runtime_rerank_model.execution_context,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,9 @@ import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import monitoring as persistence_monitoring
|
||||
from ..authz import WorkspaceRequiredError
|
||||
from ..context import ExecutionContext
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
@@ -17,9 +20,26 @@ class MonitoringService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
@staticmethod
|
||||
def _require_write_context(context: ExecutionContext | None) -> str:
|
||||
"""Reject background/runtime writes that lost their execution fence."""
|
||||
|
||||
if not isinstance(context, ExecutionContext):
|
||||
raise WorkspaceRequiredError('Monitoring writes require an ExecutionContext')
|
||||
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
|
||||
raise WorkspaceRequiredError('Monitoring writes require an instance and Workspace')
|
||||
if context.placement_generation <= 0:
|
||||
raise WorkspaceRequiredError('Monitoring writes require a positive placement generation')
|
||||
return context.workspace_uuid
|
||||
|
||||
# ========== Cleanup Methods ==========
|
||||
|
||||
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
|
||||
async def cleanup_expired_records(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
retention_days: int,
|
||||
batch_size: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
"""Delete monitoring records older than the specified retention period.
|
||||
|
||||
Args:
|
||||
@@ -29,6 +49,7 @@ class MonitoringService:
|
||||
Returns:
|
||||
A dict mapping table name to the number of deleted rows.
|
||||
"""
|
||||
self._require_write_context(context)
|
||||
if retention_days < 1:
|
||||
raise ValueError('retention_days must be >= 1')
|
||||
if batch_size < 1:
|
||||
@@ -87,6 +108,7 @@ class MonitoringService:
|
||||
|
||||
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
|
||||
deleted_counts[table_name] = await self._delete_expired_in_batches(
|
||||
context=context,
|
||||
model_cls=model_cls,
|
||||
ts_column=ts_column,
|
||||
pk_column=pk_column,
|
||||
@@ -101,24 +123,31 @@ class MonitoringService:
|
||||
|
||||
async def _delete_expired_in_batches(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
model_cls: type,
|
||||
ts_column: sqlalchemy.Column,
|
||||
pk_column: sqlalchemy.Column,
|
||||
cutoff: datetime.datetime,
|
||||
batch_size: int,
|
||||
) -> int:
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
deleted_total = 0
|
||||
|
||||
while True:
|
||||
select_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
|
||||
sqlalchemy.select(pk_column)
|
||||
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pk_values = list(select_result.scalars().all())
|
||||
if not pk_values:
|
||||
break
|
||||
|
||||
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
|
||||
sqlalchemy.delete(model_cls).where(
|
||||
model_cls.workspace_uuid == workspace_uuid,
|
||||
pk_column.in_(pk_values),
|
||||
)
|
||||
)
|
||||
deleted = delete_result.rowcount or 0
|
||||
deleted_total += deleted
|
||||
@@ -158,13 +187,16 @@ class MonitoringService:
|
||||
|
||||
async def _get_message_for_tool_context(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
message_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
):
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
if message_id:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
|
||||
persistence_monitoring.MonitoringMessage.id == message_id
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.id == message_id,
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
@@ -180,6 +212,7 @@ class MonitoringService:
|
||||
sqlalchemy.and_(
|
||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
||||
persistence_monitoring.MonitoringMessage.role == 'user',
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
)
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
|
||||
@@ -192,7 +225,10 @@ class MonitoringService:
|
||||
|
||||
any_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
|
||||
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -204,6 +240,7 @@ class MonitoringService:
|
||||
|
||||
async def record_message(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
@@ -220,9 +257,11 @@ class MonitoringService:
|
||||
role: str = 'user',
|
||||
) -> str:
|
||||
"""Record a message"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
message_id = str(uuid.uuid4())
|
||||
message_data = {
|
||||
'id': message_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
@@ -248,6 +287,7 @@ class MonitoringService:
|
||||
|
||||
async def record_llm_call(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
@@ -263,9 +303,11 @@ class MonitoringService:
|
||||
message_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record an LLM call"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
call_id = str(uuid.uuid4())
|
||||
call_data = {
|
||||
'id': call_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'model_name': model_name,
|
||||
'input_tokens': input_tokens,
|
||||
@@ -291,6 +333,7 @@ class MonitoringService:
|
||||
|
||||
async def record_tool_call(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
tool_name: str,
|
||||
tool_source: str,
|
||||
duration: int,
|
||||
@@ -306,7 +349,12 @@ class MonitoringService:
|
||||
error_message: str | None = None,
|
||||
) -> str:
|
||||
"""Record a tool call."""
|
||||
context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
context_message = await self._get_message_for_tool_context(
|
||||
context,
|
||||
message_id=message_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
if context_message:
|
||||
bot_id = bot_id or context_message.bot_id
|
||||
bot_name = bot_name or context_message.bot_name
|
||||
@@ -318,6 +366,7 @@ class MonitoringService:
|
||||
call_id = str(uuid.uuid4())
|
||||
call_data = {
|
||||
'id': call_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'tool_name': tool_name,
|
||||
'tool_source': tool_source,
|
||||
@@ -342,6 +391,7 @@ class MonitoringService:
|
||||
|
||||
async def record_embedding_call(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
model_name: str,
|
||||
prompt_tokens: int,
|
||||
total_tokens: int,
|
||||
@@ -356,9 +406,11 @@ class MonitoringService:
|
||||
call_type: str | None = None,
|
||||
) -> str:
|
||||
"""Record an embedding call"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
call_id = str(uuid.uuid4())
|
||||
call_data = {
|
||||
'id': call_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'model_name': model_name,
|
||||
'prompt_tokens': prompt_tokens,
|
||||
@@ -382,6 +434,7 @@ class MonitoringService:
|
||||
|
||||
async def record_session_start(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
session_id: str,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
@@ -392,7 +445,9 @@ class MonitoringService:
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
session_data = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'session_id': session_id,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
@@ -413,6 +468,7 @@ class MonitoringService:
|
||||
|
||||
async def update_session_activity(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
session_id: str,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
@@ -424,6 +480,7 @@ class MonitoringService:
|
||||
Returns:
|
||||
True if session was found and updated, False if session doesn't exist.
|
||||
"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
update_values = {
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
||||
@@ -437,7 +494,10 @@ class MonitoringService:
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringSession)
|
||||
.where(persistence_monitoring.MonitoringSession.session_id == session_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
)
|
||||
.values(update_values)
|
||||
)
|
||||
# Check if any rows were updated
|
||||
@@ -445,6 +505,7 @@ class MonitoringService:
|
||||
|
||||
async def record_error(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
@@ -456,9 +517,11 @@ class MonitoringService:
|
||||
message_id: str | None = None,
|
||||
) -> str:
|
||||
"""Record an error"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
error_id = str(uuid.uuid4())
|
||||
error_data = {
|
||||
'id': error_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'error_type': error_type,
|
||||
'error_message': error_message,
|
||||
@@ -479,12 +542,14 @@ class MonitoringService:
|
||||
|
||||
async def update_message_status(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
message_id: str,
|
||||
status: str,
|
||||
level: str | None = None,
|
||||
variables: str | None = None,
|
||||
) -> None:
|
||||
"""Update message status and optionally variables"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
update_values = {'status': status}
|
||||
if level is not None:
|
||||
update_values['level'] = level
|
||||
@@ -493,7 +558,10 @@ class MonitoringService:
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
|
||||
.where(persistence_monitoring.MonitoringMessage.id == message_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.id == message_id,
|
||||
)
|
||||
.values(update_values)
|
||||
)
|
||||
|
||||
@@ -501,17 +569,19 @@ class MonitoringService:
|
||||
|
||||
async def get_overview_metrics(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get overview metrics"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Build base query conditions
|
||||
message_conditions = []
|
||||
llm_conditions = []
|
||||
embedding_conditions = []
|
||||
session_conditions = []
|
||||
message_conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
||||
llm_conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
|
||||
embedding_conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
|
||||
session_conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
||||
@@ -594,6 +664,7 @@ class MonitoringService:
|
||||
|
||||
async def get_token_statistics(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -612,8 +683,9 @@ class MonitoringService:
|
||||
token accounting.
|
||||
"""
|
||||
LLMCall = persistence_monitoring.MonitoringLLMCall
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
|
||||
conditions = []
|
||||
conditions = [LLMCall.workspace_uuid == workspace_uuid]
|
||||
if bot_ids:
|
||||
conditions.append(LLMCall.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
@@ -767,6 +839,7 @@ class MonitoringService:
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
session_ids: list[str] | None = None,
|
||||
@@ -776,7 +849,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get messages with filters"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
||||
@@ -820,6 +894,7 @@ class MonitoringService:
|
||||
|
||||
async def get_llm_calls(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -828,7 +903,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get LLM calls with filters"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
||||
@@ -871,6 +947,7 @@ class MonitoringService:
|
||||
|
||||
async def get_tool_calls(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
session_ids: list[str] | None = None,
|
||||
@@ -880,7 +957,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get tool calls with filters"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
|
||||
@@ -923,6 +1001,7 @@ class MonitoringService:
|
||||
|
||||
async def get_embedding_calls(
|
||||
self,
|
||||
context: TenantContext,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
knowledge_base_id: str | None = None,
|
||||
@@ -930,7 +1009,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get embedding calls with filters"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
|
||||
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
@@ -971,6 +1051,7 @@ class MonitoringService:
|
||||
|
||||
async def get_sessions(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -980,7 +1061,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get sessions with filters"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
||||
@@ -1025,6 +1107,7 @@ class MonitoringService:
|
||||
|
||||
async def get_errors(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1033,7 +1116,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get errors with filters"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
|
||||
@@ -1076,12 +1160,15 @@ class MonitoringService:
|
||||
|
||||
async def get_session_analysis(
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
) -> dict:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Get session info
|
||||
session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query)
|
||||
session_row = session_result.first()
|
||||
@@ -1097,7 +1184,10 @@ class MonitoringService:
|
||||
# Get messages for this session
|
||||
messages_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
|
||||
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringMessage.timestamp.asc())
|
||||
)
|
||||
messages_result = await self.ap.persistence_mgr.execute_async(messages_query)
|
||||
@@ -1118,7 +1208,8 @@ class MonitoringService:
|
||||
|
||||
# Get LLM calls for this session
|
||||
llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where(
|
||||
persistence_monitoring.MonitoringLLMCall.session_id == session_id
|
||||
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
|
||||
)
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
||||
llm_rows = llm_result.all()
|
||||
@@ -1146,7 +1237,10 @@ class MonitoringService:
|
||||
# Get tool calls for this session
|
||||
tool_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||
.where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||
)
|
||||
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
|
||||
@@ -1174,7 +1268,10 @@ class MonitoringService:
|
||||
# Get errors for this session
|
||||
error_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
||||
.where(persistence_monitoring.MonitoringError.session_id == session_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringError.session_id == session_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
|
||||
)
|
||||
error_result = await self.ap.persistence_mgr.execute_async(error_query)
|
||||
@@ -1228,12 +1325,15 @@ class MonitoringService:
|
||||
|
||||
async def get_message_details(
|
||||
self,
|
||||
context: TenantContext,
|
||||
message_id: str,
|
||||
) -> dict:
|
||||
"""Get detailed information for a specific message including associated LLM calls and errors"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Get message info
|
||||
message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
|
||||
persistence_monitoring.MonitoringMessage.id == message_id
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.id == message_id,
|
||||
)
|
||||
message_result = await self.ap.persistence_mgr.execute_async(message_query)
|
||||
message_row = message_result.first()
|
||||
@@ -1249,7 +1349,10 @@ class MonitoringService:
|
||||
# Get LLM calls for this message
|
||||
llm_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)
|
||||
.where(persistence_monitoring.MonitoringLLMCall.message_id == message_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringLLMCall.message_id == message_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())
|
||||
)
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
|
||||
@@ -1271,7 +1374,10 @@ class MonitoringService:
|
||||
# Get errors for this message
|
||||
error_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
||||
.where(persistence_monitoring.MonitoringError.message_id == message_id)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringError.message_id == message_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.asc())
|
||||
)
|
||||
error_result = await self.ap.persistence_mgr.execute_async(error_query)
|
||||
@@ -1379,6 +1485,7 @@ class MonitoringService:
|
||||
|
||||
async def export_messages(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1386,7 +1493,8 @@ class MonitoringService:
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export messages as list of dictionaries for CSV conversion"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
|
||||
@@ -1432,6 +1540,7 @@ class MonitoringService:
|
||||
|
||||
async def export_llm_calls(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1439,7 +1548,8 @@ class MonitoringService:
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export LLM calls as list of dictionaries for CSV conversion"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
|
||||
@@ -1485,13 +1595,15 @@ class MonitoringService:
|
||||
|
||||
async def export_embedding_calls(
|
||||
self,
|
||||
context: TenantContext,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
knowledge_base_id: str | None = None,
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export embedding calls as list of dictionaries for CSV conversion"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
|
||||
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
@@ -1533,6 +1645,7 @@ class MonitoringService:
|
||||
|
||||
async def export_errors(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1540,7 +1653,8 @@ class MonitoringService:
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export errors as list of dictionaries for CSV conversion"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
|
||||
@@ -1581,6 +1695,7 @@ class MonitoringService:
|
||||
|
||||
async def export_sessions(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1588,7 +1703,8 @@ class MonitoringService:
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export sessions as list of dictionaries for CSV conversion"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
||||
@@ -1633,6 +1749,7 @@ class MonitoringService:
|
||||
|
||||
async def record_feedback(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
feedback_id: str,
|
||||
feedback_type: int,
|
||||
feedback_content: str | None = None,
|
||||
@@ -1646,7 +1763,7 @@ class MonitoringService:
|
||||
stream_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str:
|
||||
) -> str | None:
|
||||
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||
|
||||
Args:
|
||||
@@ -1669,6 +1786,7 @@ class MonitoringService:
|
||||
"""
|
||||
import json
|
||||
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||
|
||||
@@ -1677,13 +1795,19 @@ class MonitoringService:
|
||||
# Handle cancel feedback (type=3): delete existing record
|
||||
if feedback_type == 3:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
|
||||
sqlalchemy.delete(MonitoringFeedback).where(
|
||||
MonitoringFeedback.workspace_uuid == workspace_uuid,
|
||||
MonitoringFeedback.feedback_id == feedback_id,
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if record with this feedback_id already exists
|
||||
existing_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
|
||||
sqlalchemy.select(MonitoringFeedback).where(
|
||||
MonitoringFeedback.workspace_uuid == workspace_uuid,
|
||||
MonitoringFeedback.feedback_id == feedback_id,
|
||||
)
|
||||
)
|
||||
existing_row = existing_result.first()
|
||||
|
||||
@@ -1692,7 +1816,10 @@ class MonitoringService:
|
||||
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(MonitoringFeedback)
|
||||
.where(MonitoringFeedback.feedback_id == feedback_id)
|
||||
.where(
|
||||
MonitoringFeedback.workspace_uuid == workspace_uuid,
|
||||
MonitoringFeedback.feedback_id == feedback_id,
|
||||
)
|
||||
.values(
|
||||
timestamp=now,
|
||||
feedback_type=feedback_type,
|
||||
@@ -1715,6 +1842,7 @@ class MonitoringService:
|
||||
record_id = str(uuid.uuid4())
|
||||
record_data = {
|
||||
'id': record_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'timestamp': now,
|
||||
'feedback_id': feedback_id,
|
||||
'feedback_type': feedback_type,
|
||||
@@ -1737,7 +1865,10 @@ class MonitoringService:
|
||||
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(MonitoringFeedback)
|
||||
.where(MonitoringFeedback.feedback_id == feedback_id)
|
||||
.where(
|
||||
MonitoringFeedback.workspace_uuid == workspace_uuid,
|
||||
MonitoringFeedback.feedback_id == feedback_id,
|
||||
)
|
||||
.values(
|
||||
timestamp=now,
|
||||
feedback_type=feedback_type,
|
||||
@@ -1749,6 +1880,7 @@ class MonitoringService:
|
||||
|
||||
async def get_feedback_stats(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1759,7 +1891,8 @@ class MonitoringService:
|
||||
Returns:
|
||||
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
|
||||
"""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||
@@ -1837,6 +1970,7 @@ class MonitoringService:
|
||||
|
||||
async def get_feedback_list(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
feedback_type: int | None = None,
|
||||
@@ -1846,7 +1980,8 @@ class MonitoringService:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get feedback list with filters."""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||
@@ -1889,6 +2024,7 @@ class MonitoringService:
|
||||
|
||||
async def export_feedback(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
@@ -1896,7 +2032,8 @@ class MonitoringService:
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export feedback as list of dictionaries for CSV conversion."""
|
||||
conditions = []
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||
|
||||
@@ -6,6 +6,9 @@ import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
|
||||
default_stage_order = [
|
||||
@@ -30,7 +33,8 @@ class PipelineService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_pipeline_metadata(self) -> list[dict]:
|
||||
async def get_pipeline_metadata(self, context: TenantContext) -> list[dict]:
|
||||
require_workspace_uuid(context)
|
||||
return [
|
||||
self.ap.pipeline_config_meta_trigger,
|
||||
self.ap.pipeline_config_meta_safety,
|
||||
@@ -38,8 +42,19 @@ class PipelineService:
|
||||
self.ap.pipeline_config_meta_output,
|
||||
]
|
||||
|
||||
async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]:
|
||||
query = sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||
async def get_pipelines(
|
||||
self,
|
||||
context: TenantContext,
|
||||
sort_by: str = 'created_at',
|
||||
sort_order: str = 'DESC',
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> list[dict]:
|
||||
query = scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
context,
|
||||
)
|
||||
|
||||
if sort_by == 'created_at':
|
||||
if sort_order == 'DESC':
|
||||
@@ -54,15 +69,26 @@ class PipelineService:
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
pipelines = result.all()
|
||||
return [
|
||||
serialized = [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
for pipeline in pipelines
|
||||
]
|
||||
return serialized if include_secret else [redact_secrets(pipeline) for pipeline in serialized]
|
||||
|
||||
async def get_pipeline(self, pipeline_uuid: str) -> dict | None:
|
||||
async def get_pipeline(
|
||||
self,
|
||||
context: TenantContext,
|
||||
pipeline_uuid: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
context,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -71,20 +97,24 @@ class PipelineService:
|
||||
if pipeline is None:
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
serialized = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
return serialized if include_secret else redact_secrets(serialized)
|
||||
|
||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||
async def create_pipeline(self, context: TenantContext, pipeline_data: dict, default: bool = False) -> str:
|
||||
from ....utils import paths as path_utils
|
||||
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_pipelines = limitation.get('max_pipelines', -1)
|
||||
if max_pipelines >= 0:
|
||||
existing_pipelines = await self.get_pipelines()
|
||||
existing_pipelines = await self.get_pipelines(context)
|
||||
if len(existing_pipelines) >= max_pipelines:
|
||||
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
|
||||
|
||||
pipeline_data = pipeline_data.copy()
|
||||
pipeline_data['uuid'] = str(uuid.uuid4())
|
||||
pipeline_data['workspace_uuid'] = workspace_uuid
|
||||
pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version()
|
||||
pipeline_data['stages'] = default_stage_order.copy()
|
||||
pipeline_data['is_default'] = default
|
||||
@@ -108,79 +138,122 @@ class PipelineService:
|
||||
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data)
|
||||
)
|
||||
|
||||
pipeline = await self.get_pipeline(pipeline_data['uuid'])
|
||||
pipeline = await self.get_pipeline(context, pipeline_data['uuid'], include_secret=True)
|
||||
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
|
||||
|
||||
return pipeline_data['uuid']
|
||||
|
||||
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||
async def update_pipeline(self, context: TenantContext, pipeline_uuid: str, pipeline_data: dict) -> None:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
pipeline_data = pipeline_data.copy()
|
||||
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
|
||||
for protected_field in ('uuid', 'workspace_uuid', 'for_version', 'stages', 'is_default'):
|
||||
pipeline_data.pop(protected_field, None)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||
.values(**pipeline_data)
|
||||
)
|
||||
if 'config' in pipeline_data:
|
||||
current_config = None
|
||||
if contains_secret_placeholder(pipeline_data['config']):
|
||||
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
|
||||
if current_pipeline is None:
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
current_config = current_pipeline.get('config', {})
|
||||
pipeline_data['config'] = restore_secret_placeholders(
|
||||
pipeline_data['config'],
|
||||
current_config if current_config is not None else {},
|
||||
)
|
||||
|
||||
pipeline = await self.get_pipeline(pipeline_uuid)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||
.values(**pipeline_data),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
|
||||
pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
|
||||
if pipeline is None:
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
|
||||
if 'name' in pipeline_data:
|
||||
from ....entity.persistence import bot as persistence_bot
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_bot.Bot).where(
|
||||
persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid
|
||||
),
|
||||
persistence_bot.Bot,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
bots = result.all()
|
||||
|
||||
for bot in bots:
|
||||
bot_data = {'use_pipeline_name': pipeline_data['name']}
|
||||
await self.ap.bot_service.update_bot(bot.uuid, bot_data)
|
||||
await self.ap.bot_service.update_bot(context, bot.uuid, bot_data)
|
||||
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
|
||||
|
||||
# update all conversation that use this pipeline
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid:
|
||||
if (
|
||||
session.using_conversation is not None
|
||||
and session.using_conversation.pipeline_uuid == pipeline_uuid
|
||||
and getattr(session, 'workspace_uuid', workspace_uuid) == workspace_uuid
|
||||
):
|
||||
session.using_conversation = None
|
||||
|
||||
async def delete_pipeline(self, pipeline_uuid: str) -> None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
async def delete_pipeline(self, context: TenantContext, pipeline_uuid: str) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
context,
|
||||
)
|
||||
)
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
|
||||
|
||||
async def copy_pipeline(self, pipeline_uuid: str) -> str:
|
||||
async def copy_pipeline(self, context: TenantContext, pipeline_uuid: str) -> str:
|
||||
"""Copy a pipeline with all its configurations"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
max_pipelines = limitation.get('max_pipelines', -1)
|
||||
if max_pipelines >= 0:
|
||||
existing_pipelines = await self.get_pipelines()
|
||||
existing_pipelines = await self.get_pipelines(context)
|
||||
if len(existing_pipelines) >= max_pipelines:
|
||||
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
|
||||
|
||||
# Get the original pipeline
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
original_pipeline = result.first()
|
||||
if original_pipeline is None:
|
||||
raise ValueError(f'Pipeline {pipeline_uuid} not found')
|
||||
raise WorkspaceNotFoundError(f'Pipeline {pipeline_uuid} not found')
|
||||
|
||||
# Create new pipeline data
|
||||
new_uuid = str(uuid.uuid4())
|
||||
new_pipeline_data = {
|
||||
'uuid': new_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'name': f'{original_pipeline.name} (Copy)',
|
||||
'description': original_pipeline.description,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
@@ -207,13 +280,14 @@ class PipelineService:
|
||||
)
|
||||
|
||||
# Load the new pipeline
|
||||
pipeline = await self.get_pipeline(new_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
pipeline = await self.get_pipeline(context, new_uuid, include_secret=True)
|
||||
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
|
||||
|
||||
return new_uuid
|
||||
|
||||
async def update_pipeline_extensions(
|
||||
self,
|
||||
context: TenantContext,
|
||||
pipeline_uuid: str,
|
||||
bound_plugins: list[dict],
|
||||
bound_mcp_servers: list[str] = None,
|
||||
@@ -225,16 +299,21 @@ class PipelineService:
|
||||
mcp_resource_agent_read_enabled: bool | None = None,
|
||||
) -> None:
|
||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Get current pipeline
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
pipeline = result.first()
|
||||
if pipeline is None:
|
||||
raise ValueError(f'Pipeline {pipeline_uuid} not found')
|
||||
raise WorkspaceNotFoundError(f'Pipeline {pipeline_uuid} not found')
|
||||
|
||||
# Update extensions_preferences
|
||||
extensions_preferences = pipeline.extensions_preferences or {}
|
||||
@@ -252,12 +331,16 @@ class PipelineService:
|
||||
extensions_preferences['mcp_resources'] = bound_mcp_resources
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||
.values(extensions_preferences=extensions_preferences)
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
|
||||
.values(extensions_preferences=extensions_preferences),
|
||||
persistence_pipeline.LegacyPipeline,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
# Reload pipeline to apply changes
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
pipeline = await self.get_pipeline(pipeline_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
|
||||
pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
|
||||
await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
|
||||
|
||||
@@ -7,6 +7,9 @@ import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
|
||||
class ModelProviderService:
|
||||
@@ -35,9 +38,15 @@ class ModelProviderService:
|
||||
|
||||
return normalized_keys
|
||||
|
||||
async def get_providers(self) -> list[dict]:
|
||||
async def get_providers(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
||||
"""Get all providers"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
providers = result.all()
|
||||
providers_list = []
|
||||
for p in providers:
|
||||
@@ -50,14 +59,25 @@ class ModelProviderService:
|
||||
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
|
||||
except Exception:
|
||||
provider_dict['api_keys'] = []
|
||||
if not include_secret:
|
||||
provider_dict = redact_secrets(provider_dict)
|
||||
providers_list.append(provider_dict)
|
||||
return providers_list
|
||||
|
||||
async def get_provider(self, provider_uuid: str) -> dict | None:
|
||||
async def get_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
"""Get a single provider by UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
provider = result.first()
|
||||
@@ -72,103 +92,171 @@ class ModelProviderService:
|
||||
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
|
||||
except Exception:
|
||||
provider_dict['api_keys'] = []
|
||||
if not include_secret:
|
||||
provider_dict = redact_secrets(provider_dict)
|
||||
return provider_dict
|
||||
|
||||
async def create_provider(self, provider_data: dict) -> str:
|
||||
async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
|
||||
"""Create a new provider"""
|
||||
provider_data = provider_data.copy()
|
||||
provider_data['uuid'] = str(uuid.uuid4())
|
||||
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||
provider_data['api_keys'] = self._normalize_api_keys(
|
||||
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
|
||||
# load to runtime
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(provider_data)
|
||||
self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
|
||||
await self.ap.model_mgr.cache_provider(context, runtime_provider)
|
||||
return provider_data['uuid']
|
||||
|
||||
async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:
|
||||
async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
|
||||
"""Update an existing provider"""
|
||||
if 'uuid' in provider_data:
|
||||
del provider_data['uuid']
|
||||
provider_data = provider_data.copy()
|
||||
provider_data.pop('uuid', None)
|
||||
provider_data.pop('workspace_uuid', None)
|
||||
if 'api_keys' in provider_data:
|
||||
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||
.values(**provider_data)
|
||||
submitted_keys = provider_data.get('api_keys')
|
||||
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
||||
current_provider = await self.get_provider(context, provider_uuid, include_secret=True)
|
||||
if current_provider is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
submitted_keys = restore_secret_placeholders(
|
||||
submitted_keys,
|
||||
current_provider.get('api_keys', []),
|
||||
sensitive=True,
|
||||
)
|
||||
provider_data['api_keys'] = self._normalize_api_keys(submitted_keys)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||
.values(**provider_data),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
await self.ap.model_mgr.reload_provider(provider_uuid)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
||||
|
||||
async def delete_provider(self, provider_uuid: str) -> None:
|
||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
"""Delete a provider (only if no models reference it)"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check if any models use this provider
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.LLMModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
if llm_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: LLM models still reference it')
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.EmbeddingModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
if embedding_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||
|
||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.RerankModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
if rerank_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
|
||||
await self.ap.model_mgr.remove_provider(provider_uuid)
|
||||
await self.ap.model_mgr.remove_provider(context, provider_uuid)
|
||||
|
||||
async def get_provider_model_counts(self, provider_uuid: str) -> dict:
|
||||
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
|
||||
"""Get count of models using this provider"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if await self.get_provider(context, provider_uuid) is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.provider_uuid == provider_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.provider_uuid == provider_uuid),
|
||||
persistence_model.LLMModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
llm_count = llm_result.scalar() or 0
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid),
|
||||
persistence_model.EmbeddingModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
embedding_count = embedding_result.scalar() or 0
|
||||
|
||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.RerankModel)
|
||||
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
|
||||
scope_statement(
|
||||
sqlalchemy.select(sqlalchemy.func.count())
|
||||
.select_from(persistence_model.RerankModel)
|
||||
.where(persistence_model.RerankModel.provider_uuid == provider_uuid),
|
||||
persistence_model.RerankModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
rerank_count = rerank_result.scalar() or 0
|
||||
|
||||
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
|
||||
|
||||
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
|
||||
async def find_or_create_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
requester: str,
|
||||
base_url: str,
|
||||
api_keys: list,
|
||||
) -> str:
|
||||
"""Find existing provider or create new one"""
|
||||
api_keys = self._normalize_api_keys(api_keys)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
|
||||
|
||||
# Try to find existing provider with same config
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.requester == requester,
|
||||
persistence_model.ModelProvider.base_url == base_url,
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.requester == requester,
|
||||
persistence_model.ModelProvider.base_url == base_url,
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
for provider in result.all():
|
||||
@@ -187,29 +275,38 @@ class ModelProviderService:
|
||||
pass
|
||||
|
||||
return await self.create_provider(
|
||||
context,
|
||||
{
|
||||
'name': provider_name,
|
||||
'requester': requester,
|
||||
'base_url': base_url,
|
||||
'api_keys': api_keys,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def update_space_model_provider_api_keys(self, api_key: str) -> None:
|
||||
async def update_space_model_provider_api_keys(self, context: TenantContext, api_key: str) -> None:
|
||||
"""Update Space model provider API keys"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||
.values(api_keys=self._normalize_api_keys(api_key))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
|
||||
.values(api_keys=self._normalize_api_keys(api_key)),
|
||||
persistence_model.ModelProvider,
|
||||
context,
|
||||
)
|
||||
)
|
||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
await self.ap.model_mgr.reload_provider(context, '00000000-0000-0000-0000-000000000000')
|
||||
|
||||
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
|
||||
provider = await self.get_provider(provider_uuid)
|
||||
async def scan_provider_models(
|
||||
self, context: TenantContext, provider_uuid: str, model_type: str | None = None
|
||||
) -> dict:
|
||||
provider = await self.get_provider(context, provider_uuid, include_secret=True)
|
||||
if provider is None:
|
||||
raise ValueError('provider not found')
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(provider)
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(context, provider)
|
||||
|
||||
try:
|
||||
scan_result = await runtime_provider.requester.scan_models(
|
||||
@@ -230,8 +327,10 @@ class ModelProviderService:
|
||||
scanned_models = scan_result
|
||||
debug_info = None
|
||||
|
||||
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
|
||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
|
||||
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(context, provider_uuid)
|
||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
context, provider_uuid
|
||||
)
|
||||
existing_llm_names = {model['name'] for model in llm_models}
|
||||
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
|
||||
SECRET_MASK = '***'
|
||||
_MISSING_SECRET = object()
|
||||
|
||||
_SENSITIVE_NAMES = frozenset(
|
||||
{
|
||||
'api_key',
|
||||
'api_keys',
|
||||
'apikey',
|
||||
'apikeys',
|
||||
'auth',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'credentials',
|
||||
'database_url',
|
||||
'dsn',
|
||||
'header_value',
|
||||
'key',
|
||||
'proxy_authorization',
|
||||
'set_cookie',
|
||||
'webhook_url',
|
||||
}
|
||||
)
|
||||
_SENSITIVE_TOKENS = frozenset(
|
||||
{
|
||||
'apikey',
|
||||
'credential',
|
||||
'credentials',
|
||||
'passwd',
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
}
|
||||
)
|
||||
_KEY_QUALIFIERS = frozenset(
|
||||
{
|
||||
'access',
|
||||
'api',
|
||||
'auth',
|
||||
'bearer',
|
||||
'client',
|
||||
'debug',
|
||||
'encryption',
|
||||
'private',
|
||||
'signing',
|
||||
}
|
||||
)
|
||||
_SENSITIVE_URL_QUERY_NAMES = frozenset(
|
||||
{
|
||||
'code',
|
||||
'credential',
|
||||
'credentials',
|
||||
'password',
|
||||
'passwd',
|
||||
'sig',
|
||||
'signature',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_key(key: object) -> str:
|
||||
value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
|
||||
return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
|
||||
|
||||
|
||||
def is_sensitive_key(key: object) -> bool:
|
||||
"""Return whether a configuration key conventionally carries a secret."""
|
||||
|
||||
normalized = _normalize_key(key)
|
||||
if normalized in _SENSITIVE_NAMES:
|
||||
return True
|
||||
tokens = frozenset(token for token in normalized.split('_') if token)
|
||||
if tokens & _SENSITIVE_TOKENS:
|
||||
return True
|
||||
return bool(tokens & {'key', 'keys'}) and bool(tokens & _KEY_QUALIFIERS)
|
||||
|
||||
|
||||
def is_url_key(key: object) -> bool:
|
||||
"""Return whether a configuration field conventionally carries a URL."""
|
||||
|
||||
normalized = _normalize_key(key)
|
||||
return normalized == 'url' or normalized.endswith('_url')
|
||||
|
||||
|
||||
def _is_sensitive_url_query_key(key: object) -> bool:
|
||||
normalized = _normalize_key(key)
|
||||
return (
|
||||
is_sensitive_key(key) or normalized in _SENSITIVE_URL_QUERY_NAMES or normalized.endswith(('_sig', '_signature'))
|
||||
)
|
||||
|
||||
|
||||
def _redact_url_string(value: str) -> str:
|
||||
if not value:
|
||||
return value
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
netloc = parsed.netloc
|
||||
if '@' in netloc:
|
||||
_, host = netloc.rsplit('@', 1)
|
||||
netloc = f'{SECRET_MASK}@{host}'
|
||||
query = urlencode(
|
||||
[
|
||||
(key, SECRET_MASK if _is_sensitive_url_query_key(key) and item else item)
|
||||
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
|
||||
],
|
||||
doseq=True,
|
||||
safe='*',
|
||||
)
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path, query, parsed.fragment))
|
||||
except (TypeError, ValueError):
|
||||
# A malformed URL cannot be safely decomposed, so fail closed.
|
||||
return SECRET_MASK
|
||||
|
||||
|
||||
def redact_url_secrets(value):
|
||||
"""Redact URL userinfo and credential-like query values."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return _redact_url_string(value)
|
||||
if isinstance(value, list):
|
||||
return [redact_url_secrets(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_url_secrets(item) for item in value)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def _contains_url_secret_placeholder(value) -> bool:
|
||||
if isinstance(value, str):
|
||||
if value == SECRET_MASK:
|
||||
return True
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
if '@' in parsed.netloc and SECRET_MASK in parsed.netloc.rsplit('@', 1)[0]:
|
||||
return True
|
||||
return any(
|
||||
item == SECRET_MASK and _is_sensitive_url_query_key(key)
|
||||
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(_contains_url_secret_placeholder(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _restore_url_string(value: str, current_value) -> str:
|
||||
if value == SECRET_MASK:
|
||||
if current_value is _MISSING_SECRET:
|
||||
raise ValueError('Masked URL secret has no existing value')
|
||||
return copy.deepcopy(current_value)
|
||||
|
||||
try:
|
||||
submitted = urlsplit(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
|
||||
current = None
|
||||
if isinstance(current_value, str):
|
||||
try:
|
||||
current = urlsplit(current_value)
|
||||
except (TypeError, ValueError):
|
||||
current = None
|
||||
|
||||
netloc = submitted.netloc
|
||||
if '@' in netloc:
|
||||
submitted_userinfo, host = netloc.rsplit('@', 1)
|
||||
if SECRET_MASK in submitted_userinfo:
|
||||
if current is None or '@' not in current.netloc:
|
||||
raise ValueError('Masked URL userinfo has no existing value')
|
||||
current_userinfo, _ = current.netloc.rsplit('@', 1)
|
||||
netloc = f'{current_userinfo}@{host}'
|
||||
|
||||
current_query: dict[str, list[str]] = {}
|
||||
if current is not None:
|
||||
for key, item in parse_qsl(current.query, keep_blank_values=True):
|
||||
current_query.setdefault(_normalize_key(key), []).append(item)
|
||||
consumed: dict[str, int] = {}
|
||||
restored_query: list[tuple[str, str]] = []
|
||||
for key, item in parse_qsl(submitted.query, keep_blank_values=True):
|
||||
normalized = _normalize_key(key)
|
||||
if item == SECRET_MASK and _is_sensitive_url_query_key(key):
|
||||
index = consumed.get(normalized, 0)
|
||||
candidates = current_query.get(normalized, [])
|
||||
if index >= len(candidates):
|
||||
raise ValueError('Masked URL query secret has no existing value')
|
||||
item = candidates[index]
|
||||
consumed[normalized] = index + 1
|
||||
restored_query.append((key, item))
|
||||
|
||||
return urlunsplit(
|
||||
(
|
||||
submitted.scheme,
|
||||
netloc,
|
||||
submitted.path,
|
||||
urlencode(restored_query, doseq=True, safe='*'),
|
||||
submitted.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def restore_url_secret_placeholders(value, current_value=_MISSING_SECRET):
|
||||
"""Restore URL placeholders from the corresponding persisted URL."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return _restore_url_string(value, current_value)
|
||||
if isinstance(value, list):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return [
|
||||
restore_url_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return tuple(
|
||||
restore_url_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def mask_secret_value(value):
|
||||
"""Return a shape-preserving copy whose non-empty leaves are masked."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {key: mask_secret_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [mask_secret_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(mask_secret_value(item) for item in value)
|
||||
if value is None or value == '':
|
||||
return value
|
||||
return SECRET_MASK
|
||||
|
||||
|
||||
def redact_secrets(value):
|
||||
"""Return a recursively redacted copy without mutating the source value."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (
|
||||
mask_secret_value(item)
|
||||
if is_sensitive_key(key)
|
||||
else redact_url_secrets(item)
|
||||
if is_url_key(key)
|
||||
else redact_secrets(item)
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_secrets(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_secrets(item) for item in value)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def restore_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
|
||||
"""Restore masked leaves from existing data before a management write.
|
||||
|
||||
``***`` is a reserved placeholder only inside a sensitive field. A masked
|
||||
leaf without an existing counterpart is rejected so it can never become a
|
||||
persisted credential. Empty values and explicit replacements pass through.
|
||||
"""
|
||||
|
||||
if sensitive and value == SECRET_MASK:
|
||||
if current_value is _MISSING_SECRET:
|
||||
raise ValueError('Masked secret has no existing value')
|
||||
return copy.deepcopy(current_value)
|
||||
if isinstance(value, dict):
|
||||
current_mapping = current_value if isinstance(current_value, dict) else {}
|
||||
return {
|
||||
key: (
|
||||
restore_url_secret_placeholders(
|
||||
item,
|
||||
current_mapping.get(key, _MISSING_SECRET),
|
||||
)
|
||||
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
|
||||
else restore_secret_placeholders(
|
||||
item,
|
||||
current_mapping.get(key, _MISSING_SECRET),
|
||||
sensitive=sensitive or is_sensitive_key(key),
|
||||
)
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return [
|
||||
restore_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
current_items = current_value if isinstance(current_value, (list, tuple)) else ()
|
||||
return tuple(
|
||||
restore_secret_placeholders(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else _MISSING_SECRET,
|
||||
sensitive=sensitive,
|
||||
)
|
||||
for index, item in enumerate(value)
|
||||
)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def contains_secret_placeholder(value, *, sensitive: bool = False) -> bool:
|
||||
"""Return whether ``value`` contains a meaningful masked secret leaf."""
|
||||
|
||||
if sensitive and value == SECRET_MASK:
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(
|
||||
(
|
||||
_contains_url_secret_placeholder(item)
|
||||
if not sensitive and not is_sensitive_key(key) and is_url_key(key)
|
||||
else contains_secret_placeholder(item, sensitive=sensitive or is_sensitive_key(key))
|
||||
)
|
||||
for key, item in value.items()
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(contains_secret_placeholder(item, sensitive=sensitive) for item in value)
|
||||
return False
|
||||
@@ -12,6 +12,8 @@ import httpx
|
||||
|
||||
from ....core import app
|
||||
from ....skill.utils import parse_frontmatter
|
||||
from ..context import ExecutionContext
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
|
||||
_PUBLIC_SKILL_FIELDS = (
|
||||
@@ -75,75 +77,112 @@ class SkillService:
|
||||
"""Backwards-compatible alias preserved for clarity at call sites."""
|
||||
self._require_box(action)
|
||||
|
||||
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
||||
raise ValueError('Skill operations require an explicit fenced execution context')
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
workspace_uuid,
|
||||
expected_generation=generation,
|
||||
)
|
||||
if binding.instance_uuid != instance_uuid:
|
||||
raise ValueError('Skill execution context belongs to another LangBot instance')
|
||||
return ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_skill(skill: dict) -> dict:
|
||||
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
|
||||
|
||||
async def list_skills(self) -> list[dict]:
|
||||
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()]
|
||||
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
|
||||
|
||||
async def get_skill(self, skill_name: str) -> Optional[dict]:
|
||||
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(skill_name)
|
||||
skill = await box_service.get_skill(execution_context, skill_name)
|
||||
return self._serialize_skill(skill) if skill else None
|
||||
|
||||
async def get_skill_by_name(self, name: str) -> Optional[dict]:
|
||||
return await self.get_skill(name)
|
||||
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
|
||||
return await self.get_skill(context, name)
|
||||
|
||||
async def create_skill(self, data: dict) -> dict:
|
||||
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(data)
|
||||
await self._reload_skills()
|
||||
created = await box_service.create_skill(execution_context, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def update_skill(self, skill_name: str, data: dict) -> dict:
|
||||
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(skill_name, data)
|
||||
await self._reload_skills()
|
||||
updated = await box_service.update_skill(execution_context, skill_name, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(updated)
|
||||
|
||||
async def delete_skill(self, skill_name: str) -> bool:
|
||||
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(skill_name)
|
||||
await self._reload_skills()
|
||||
await box_service.delete_skill(execution_context, skill_name)
|
||||
await self._reload_skills(execution_context)
|
||||
return True
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
skill_name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
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(skill_name, path, include_hidden, max_entries)
|
||||
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
|
||||
|
||||
async def read_skill_file(self, skill_name: str, path: str) -> dict:
|
||||
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(skill_name, path)
|
||||
return await box_service.read_skill_file(execution_context, skill_name, path)
|
||||
|
||||
async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
|
||||
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(skill_name, path, content)
|
||||
await self._reload_skills()
|
||||
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
|
||||
await self._reload_skills(execution_context)
|
||||
return result
|
||||
|
||||
async def install_from_github(self, data: dict) -> list[dict]:
|
||||
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')
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
raw_asset_url = str(data['asset_url']).strip()
|
||||
if self._is_github_skill_md_url(raw_asset_url):
|
||||
return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data)
|
||||
return await self._install_github_skill_md(
|
||||
execution_context,
|
||||
raw_asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
data=data,
|
||||
)
|
||||
|
||||
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
|
||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||
@@ -151,29 +190,37 @@ 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(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
source_paths=data.get('source_paths') or [],
|
||||
source_path=str(data.get('source_path', '') or ''),
|
||||
source_subdir=source_subdir,
|
||||
)
|
||||
await self._reload_skills()
|
||||
await self._reload_skills(execution_context)
|
||||
return [self._serialize_skill(skill) for skill in installed]
|
||||
|
||||
async def preview_install_from_github(self, data: dict) -> list[dict]:
|
||||
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')
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
raw_asset_url = str(data['asset_url']).strip()
|
||||
if self._is_github_skill_md_url(raw_asset_url):
|
||||
return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo)
|
||||
return await self._preview_github_skill_md(
|
||||
execution_context,
|
||||
raw_asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
|
||||
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(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
||||
source_subdir=source_subdir,
|
||||
@@ -181,27 +228,45 @@ class SkillService:
|
||||
|
||||
async def install_from_zip_upload(
|
||||
self,
|
||||
context: TenantContext,
|
||||
*,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_paths: list[str] | None = None,
|
||||
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(
|
||||
execution_context,
|
||||
file_bytes,
|
||||
filename,
|
||||
source_paths=source_paths or [],
|
||||
source_path=source_path,
|
||||
)
|
||||
await self._reload_skills()
|
||||
await self._reload_skills(execution_context)
|
||||
return [self._serialize_skill(skill) for skill in installed]
|
||||
|
||||
async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]:
|
||||
async def preview_install_from_zip_upload(
|
||||
self,
|
||||
context: TenantContext,
|
||||
*,
|
||||
file_bytes: bytes,
|
||||
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(file_bytes, filename)
|
||||
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
|
||||
|
||||
async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]:
|
||||
async def _install_github_skill_md(
|
||||
self,
|
||||
context: TenantContext,
|
||||
asset_url: str,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
data: dict,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
@@ -210,38 +275,48 @@ class SkillService:
|
||||
)
|
||||
|
||||
installed = await box_service.install_skill_zip(
|
||||
context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
source_paths=data.get('source_paths') or [],
|
||||
source_path=str(data.get('source_path', '') or ''),
|
||||
target_suffix='',
|
||||
)
|
||||
await self._reload_skills()
|
||||
await self._reload_skills(context)
|
||||
return [self._serialize_skill(skill) for skill in installed]
|
||||
|
||||
async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]:
|
||||
async def _preview_github_skill_md(
|
||||
self,
|
||||
context: TenantContext,
|
||||
asset_url: str,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
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(zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
|
||||
async def reload_skills(self) -> list[dict]:
|
||||
await self._reload_skills()
|
||||
return await self.list_skills()
|
||||
async def reload_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
await self._reload_skills(execution_context)
|
||||
return await self.list_skills(execution_context)
|
||||
|
||||
async def scan_directory_async(self, path: str) -> dict:
|
||||
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(path)
|
||||
return await box_service.scan_skill_directory(execution_context, path)
|
||||
|
||||
async def _reload_skills(self) -> None:
|
||||
async def _reload_skills(self, context: TenantContext) -> None:
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
||||
if not callable(reload_skills):
|
||||
return
|
||||
result = reload_skills()
|
||||
result = reload_skills(context)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
@@ -85,12 +85,14 @@ class SpaceService:
|
||||
|
||||
def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
|
||||
"""Get the Space OAuth authorization URL for redirect"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = f'redirect_uri={redirect_uri}'
|
||||
params = {'redirect_uri': redirect_uri}
|
||||
if state:
|
||||
params += f'&state={state}'
|
||||
return f'{authorize_url}?{params}'
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
|
||||
async def exchange_oauth_code(self, code: str) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ..authz import WorkspaceRequiredError
|
||||
from ..context import ExecutionContext, RequestContext, WorkspaceContext
|
||||
|
||||
TenantContext: typing.TypeAlias = RequestContext | ExecutionContext | WorkspaceContext | str
|
||||
|
||||
|
||||
def require_workspace_uuid(context: TenantContext | None) -> str:
|
||||
"""Resolve an explicit Workspace UUID without allowing a global fallback."""
|
||||
|
||||
if isinstance(context, str):
|
||||
workspace_uuid = context
|
||||
elif isinstance(context, RequestContext):
|
||||
workspace_uuid = context.workspace_uuid
|
||||
elif isinstance(context, ExecutionContext):
|
||||
workspace_uuid = context.workspace_uuid
|
||||
elif isinstance(context, WorkspaceContext):
|
||||
workspace_uuid = context.workspace_uuid
|
||||
else:
|
||||
raise WorkspaceRequiredError('Workspace context is required')
|
||||
|
||||
normalized = workspace_uuid.strip()
|
||||
if not normalized:
|
||||
raise WorkspaceRequiredError('Workspace context is required')
|
||||
return normalized
|
||||
|
||||
|
||||
def scope_statement(statement: typing.Any, model: typing.Any, context: TenantContext) -> typing.Any:
|
||||
"""Add the mandatory Workspace predicate to a SQLAlchemy statement."""
|
||||
|
||||
return statement.where(model.workspace_uuid == require_workspace_uuid(context))
|
||||
@@ -6,51 +6,260 @@ import jwt
|
||||
import datetime
|
||||
import typing
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....utils import constants
|
||||
from ....entity.errors import account as account_errors
|
||||
from ....workspace.collaboration import normalize_email
|
||||
from ..authz import Permission, permissions_for_role
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
|
||||
class AccountExistsLoginRequiredError(ValueError):
|
||||
code = 'account_exists_login_required'
|
||||
|
||||
|
||||
class PublicRegistrationClosedError(ValueError):
|
||||
code = 'registration_closed'
|
||||
|
||||
|
||||
class ControlPlaneDirectoryRequiredError(PublicRegistrationClosedError):
|
||||
code = 'control_plane_required'
|
||||
|
||||
|
||||
class AccountDisabledError(ValueError):
|
||||
code = 'account_disabled'
|
||||
|
||||
|
||||
class UserService:
|
||||
ap: app.Application
|
||||
ap: Application
|
||||
_create_user_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
def __init__(self, ap: Application) -> None:
|
||||
self.ap = ap
|
||||
self._create_user_lock = asyncio.Lock()
|
||||
self._password_hash_lock = asyncio.Semaphore(1)
|
||||
self._space_oauth_state_lock = asyncio.Lock()
|
||||
self._space_oauth_states: dict[str, tuple[str, str | None, float]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _space_oauth_state_digest(state: str) -> str:
|
||||
return hashlib.sha256(state.encode('utf-8')).hexdigest()
|
||||
|
||||
async def issue_space_oauth_state(
|
||||
self,
|
||||
purpose: typing.Literal['login', 'bind'],
|
||||
*,
|
||||
account_uuid: str | None = None,
|
||||
ttl_seconds: int = 600,
|
||||
) -> str:
|
||||
"""Issue an opaque, single-use OAuth state without exposing a JWT."""
|
||||
if purpose == 'bind' and not account_uuid:
|
||||
raise ValueError('An Account is required for Space binding')
|
||||
if purpose == 'login' and account_uuid is not None:
|
||||
raise ValueError('Login state cannot be bound to an Account')
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError('OAuth state lifetime must be positive')
|
||||
|
||||
raw_state = secrets.token_urlsafe(32)
|
||||
digest = self._space_oauth_state_digest(raw_state)
|
||||
expires_at = time.monotonic() + min(ttl_seconds, 600)
|
||||
async with self._space_oauth_state_lock:
|
||||
now = time.monotonic()
|
||||
self._space_oauth_states = {key: value for key, value in self._space_oauth_states.items() if value[2] > now}
|
||||
if len(self._space_oauth_states) >= 4096:
|
||||
oldest = min(self._space_oauth_states, key=lambda key: self._space_oauth_states[key][2])
|
||||
self._space_oauth_states.pop(oldest, None)
|
||||
self._space_oauth_states[digest] = (purpose, account_uuid, expires_at)
|
||||
return raw_state
|
||||
|
||||
async def consume_space_oauth_state(
|
||||
self,
|
||||
raw_state: str,
|
||||
purpose: typing.Literal['login', 'bind'],
|
||||
) -> user.User | None:
|
||||
"""Atomically consume OAuth state and resolve its active bind Account."""
|
||||
if not isinstance(raw_state, str) or not raw_state:
|
||||
raise ValueError('Invalid or expired OAuth state')
|
||||
digest = self._space_oauth_state_digest(raw_state)
|
||||
async with self._space_oauth_state_lock:
|
||||
entry = self._space_oauth_states.pop(digest, None)
|
||||
if entry is None or entry[0] != purpose or entry[2] <= time.monotonic():
|
||||
raise ValueError('Invalid or expired OAuth state')
|
||||
if purpose == 'login':
|
||||
return None
|
||||
|
||||
account_uuid = entry[1]
|
||||
account = await self.get_user_by_uuid(account_uuid or '')
|
||||
if account is None:
|
||||
raise ValueError('Invalid or expired OAuth state')
|
||||
self._require_active_account(account)
|
||||
return account
|
||||
|
||||
async def _hash_password(self, password: str) -> str:
|
||||
async with self._password_hash_lock:
|
||||
return await asyncio.to_thread(argon2.PasswordHasher().hash, password)
|
||||
|
||||
def _require_local_directory(self) -> None:
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
if workspace_service is not None and workspace_service.policy.multi_workspace_enabled:
|
||||
raise ControlPlaneDirectoryRequiredError(
|
||||
'Cloud Accounts and directory changes are managed by the SaaS control plane'
|
||||
)
|
||||
|
||||
async def _verify_password(self, hashed_password: str, password: str) -> None:
|
||||
async with self._password_hash_lock:
|
||||
await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password)
|
||||
|
||||
async def _update_space_provider_for_account(self, account: typing.Any, api_key: str) -> None:
|
||||
"""Refresh the OSS Workspace Space provider without guessing a SaaS Workspace.
|
||||
|
||||
Space OAuth credentials belong to an Account, while model-provider secrets
|
||||
belong to a Workspace. Community edition has one unambiguous Workspace, so
|
||||
the historical automatic refresh remains available to members allowed to
|
||||
manage provider secrets. In multi-Workspace SaaS mode the OAuth callback has
|
||||
no trusted Workspace selector; the closed control plane or an explicit
|
||||
Workspace settings action must perform that linkage instead.
|
||||
"""
|
||||
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
if workspace_service is None or collaboration_service is None or not isinstance(account_uuid, str):
|
||||
# Never turn a missing tenant kernel into a global secret mutation.
|
||||
return
|
||||
if workspace_service.policy.multi_workspace_enabled:
|
||||
return
|
||||
|
||||
accesses = await collaboration_service.list_account_workspaces(account_uuid)
|
||||
if len(accesses) != 1:
|
||||
return
|
||||
access = accesses[0]
|
||||
if Permission.PROVIDER_SECRET_MANAGE.value not in permissions_for_role(access.membership.role):
|
||||
return
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(
|
||||
access.workspace.uuid,
|
||||
api_key,
|
||||
)
|
||||
|
||||
async def is_initialized(self) -> bool:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
|
||||
|
||||
result_list = result.all()
|
||||
return result_list is not None and len(result_list) > 0
|
||||
|
||||
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
|
||||
|
||||
def _jwt_identity(self) -> tuple[str, str]:
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
instance_uuid = str(getattr(workspace_service, 'instance_uuid', '') or constants.instance_id).strip()
|
||||
# UserService is constructed only after config/bootstrap in production.
|
||||
# The fallback keeps lightweight isolated unit tests deterministic.
|
||||
if not instance_uuid:
|
||||
instance_uuid = 'uninitialized-test-instance'
|
||||
return 'langbot-core', f'langbot-instance:{instance_uuid}'
|
||||
|
||||
def _legacy_local_tokens_allowed(self) -> bool:
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
policy = getattr(workspace_service, 'policy', None)
|
||||
return getattr(policy, 'multi_workspace_enabled', False) is not True
|
||||
|
||||
async def create_user(self, user_email: str, password: str) -> None:
|
||||
"""Create the first local Account and Workspace owner atomically."""
|
||||
|
||||
await self.create_initial_account(user_email, password)
|
||||
|
||||
async def create_initial_account(self, user_email: str, password: str) -> user.User:
|
||||
self._require_local_directory()
|
||||
normalized_email = normalize_email(user_email)
|
||||
hashed_password = await self._hash_password(password)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
|
||||
async with self._create_user_lock:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
existing_count = int(
|
||||
(await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(user.User))) or 0
|
||||
)
|
||||
if existing_count:
|
||||
raise PublicRegistrationClosedError('System already initialized')
|
||||
account = self._new_account(normalized_email, hashed_password)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
|
||||
return account
|
||||
|
||||
async def register_invited_account(
|
||||
self,
|
||||
invitation_token: str,
|
||||
user_email: str,
|
||||
password: str,
|
||||
) -> tuple[user.User, typing.Any, str]:
|
||||
"""Create an invited Account and accept its Membership in one transaction."""
|
||||
|
||||
self._require_local_directory()
|
||||
normalized_email = normalize_email(user_email)
|
||||
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
||||
if invitation.normalized_email != normalized_email:
|
||||
from ....workspace.collaboration import InvitationEmailMismatchError
|
||||
|
||||
raise InvitationEmailMismatchError('Invitation email does not match the Account')
|
||||
hashed_password = await self._hash_password(password)
|
||||
|
||||
async with self._create_user_lock:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
existing = await session.scalar(
|
||||
sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email)
|
||||
)
|
||||
if existing is not None:
|
||||
raise AccountExistsLoginRequiredError('An Account already exists for this email')
|
||||
account = self._new_account(normalized_email, hashed_password)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
membership = await self.ap.workspace_collaboration_service.accept_invitation(
|
||||
invitation_token,
|
||||
account.uuid,
|
||||
session=session,
|
||||
)
|
||||
token = await self.generate_jwt_token(account)
|
||||
return account, membership, token
|
||||
|
||||
def _new_account(self, normalized_email: str, hashed_password: str) -> user.User:
|
||||
return user.User(
|
||||
uuid=str(uuid.uuid4()),
|
||||
user=normalized_email,
|
||||
normalized_email=normalized_email,
|
||||
password=hashed_password,
|
||||
account_type='local',
|
||||
status=user.AccountStatus.ACTIVE.value,
|
||||
source=user.AccountSource.LOCAL.value,
|
||||
projection_revision=0,
|
||||
)
|
||||
|
||||
async def get_user_by_email(self, user_email: str) -> user.User | None:
|
||||
normalized_email = user_email.strip().casefold()
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.user == user_email)
|
||||
sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email)
|
||||
)
|
||||
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list is not None and len(result_list) > 0 else None
|
||||
|
||||
async def get_user_by_uuid(self, account_uuid: str) -> user.User | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.uuid == account_uuid)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
|
||||
"""Get user by Space account UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -61,16 +270,10 @@ class UserService:
|
||||
return result_list[0] if result_list is not None and len(result_list) > 0 else None
|
||||
|
||||
async def authenticate(self, user_email: str, password: str) -> str | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.user == user_email)
|
||||
)
|
||||
|
||||
result_list = result.all()
|
||||
|
||||
if result_list is None or len(result_list) == 0:
|
||||
user_obj = await self.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
raise ValueError('用户不存在')
|
||||
|
||||
user_obj = result_list[0]
|
||||
self._require_active_account(user_obj)
|
||||
|
||||
# Check if this user has a local password set
|
||||
if not user_obj.password:
|
||||
@@ -78,30 +281,119 @@ class UserService:
|
||||
|
||||
await self._verify_password(user_obj.password, password)
|
||||
|
||||
return await self.generate_jwt_token(user_email)
|
||||
return await self.generate_jwt_token(user_obj)
|
||||
|
||||
async def generate_jwt_token(self, user_email: 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']
|
||||
|
||||
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
|
||||
if account_obj is None and hasattr(self.ap, 'persistence_mgr'):
|
||||
try:
|
||||
account_obj = await self.get_user_by_email(user_email)
|
||||
except (AttributeError, TypeError):
|
||||
# Lightweight unit-test and bootstrap callers may not have persistence wired.
|
||||
account_obj = None
|
||||
|
||||
payload = {
|
||||
'user': user_email,
|
||||
'iss': 'LangBot-' + constants.edition,
|
||||
'iss': self._jwt_identity()[0],
|
||||
'aud': self._jwt_identity()[1],
|
||||
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire),
|
||||
}
|
||||
if account_obj is not None:
|
||||
self._require_active_account(account_obj)
|
||||
payload.update(
|
||||
{
|
||||
'sub': account_obj.uuid,
|
||||
'account_revision': account_obj.projection_revision,
|
||||
}
|
||||
)
|
||||
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256')
|
||||
|
||||
async def verify_jwt_token(self, token: str) -> str:
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
|
||||
if isinstance(account, str):
|
||||
return account
|
||||
return account.user
|
||||
|
||||
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
|
||||
async def get_authenticated_account(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
allow_unresolved_legacy: bool = False,
|
||||
) -> user.User | str:
|
||||
"""Resolve a JWT to an active Account, accepting bounded legacy email tokens."""
|
||||
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
issuer, audience = self._jwt_identity()
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=['HS256'],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
options={'require': ['exp', 'iss', 'aud']},
|
||||
)
|
||||
except jwt.MissingRequiredClaimError:
|
||||
# Preserve one bounded OSS upgrade path for previously issued
|
||||
# community tokens. SaaS/Cloud policy never accepts these tokens,
|
||||
# and a token carrying a new-style or foreign audience cannot fall
|
||||
# back into the legacy decoder.
|
||||
unverified = jwt.decode(token, options={'verify_signature': False})
|
||||
if (
|
||||
not self._legacy_local_tokens_allowed()
|
||||
or 'aud' in unverified
|
||||
or unverified.get('iss') != 'LangBot-community'
|
||||
):
|
||||
raise
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=['HS256'],
|
||||
options={'require': ['exp'], 'verify_aud': False, 'verify_iss': False},
|
||||
)
|
||||
account_obj: user.User | None = None
|
||||
account_uuid = payload.get('sub')
|
||||
if isinstance(account_uuid, str) and account_uuid:
|
||||
try:
|
||||
account_obj = await self.get_user_by_uuid(account_uuid)
|
||||
except AttributeError:
|
||||
account_obj = None
|
||||
if account_obj is None:
|
||||
legacy_email = payload.get('user')
|
||||
if not isinstance(legacy_email, str) or not legacy_email:
|
||||
raise ValueError('JWT Account identity is missing')
|
||||
try:
|
||||
account_obj = await self.get_user_by_email(legacy_email)
|
||||
except AttributeError:
|
||||
account_obj = None
|
||||
if account_obj is None and allow_unresolved_legacy:
|
||||
return legacy_email
|
||||
if account_obj is None:
|
||||
raise ValueError('Account not found')
|
||||
self._require_active_account(account_obj)
|
||||
token_revision = payload.get('account_revision')
|
||||
if token_revision is not None and int(token_revision) != account_obj.projection_revision:
|
||||
raise ValueError('Account token revision is stale')
|
||||
return account_obj
|
||||
|
||||
@staticmethod
|
||||
def _require_active_account(account: user.User) -> None:
|
||||
status = getattr(account, 'status', user.AccountStatus.ACTIVE.value)
|
||||
if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value:
|
||||
raise AccountDisabledError('Account is disabled')
|
||||
|
||||
async def reset_password(self, user_email: str, new_password: str) -> None:
|
||||
hashed_password = await self._hash_password(new_password)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.normalized_email == normalize_email(user_email))
|
||||
.values(password=hashed_password)
|
||||
)
|
||||
|
||||
async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:
|
||||
@@ -117,7 +409,9 @@ class UserService:
|
||||
hashed_password = await self._hash_password(new_password)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.normalized_email == normalize_email(user_email))
|
||||
.values(password=hashed_password)
|
||||
)
|
||||
|
||||
# Space user management
|
||||
@@ -132,6 +426,7 @@ class UserService:
|
||||
expires_in: int = 0,
|
||||
) -> user.User:
|
||||
"""Create or update a Space user account (only if system not initialized or user exists)"""
|
||||
self._require_local_directory()
|
||||
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
||||
|
||||
async with self._create_user_lock:
|
||||
@@ -150,17 +445,53 @@ class UserService:
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
await self._update_space_provider_for_account(existing_user, api_key)
|
||||
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
# Check if user with same email exists
|
||||
existing_email_user = await self.get_user_by_email(email)
|
||||
if existing_email_user:
|
||||
# Update existing user to link with Space account
|
||||
# Email is display/contact identity, not an OAuth subject. An
|
||||
# unknown Space subject must never take over an existing local
|
||||
# Account merely by presenting the same email. The Account
|
||||
# owner must first authenticate locally and use the explicit,
|
||||
# account-bound bind flow.
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
|
||||
# Check if system is already initialized
|
||||
is_initialized = await self.is_initialized()
|
||||
if is_initialized:
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
|
||||
# Create new Space user (first time initialization)
|
||||
if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'):
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
account = user.User(
|
||||
uuid=str(uuid.uuid4()),
|
||||
user=normalize_email(email),
|
||||
normalized_email=normalize_email(email),
|
||||
password='',
|
||||
account_type='space',
|
||||
status=user.AccountStatus.ACTIVE.value,
|
||||
source=user.AccountSource.LOCAL.value,
|
||||
projection_revision=0,
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
|
||||
else:
|
||||
# Compatibility path for lightweight service tests without a real engine.
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.user == email)
|
||||
.values(
|
||||
sqlalchemy.insert(user.User).values(
|
||||
user=normalize_email(email),
|
||||
normalized_email=normalize_email(email),
|
||||
password='',
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
@@ -169,30 +500,10 @@ class UserService:
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
return await self.get_user_by_email(email)
|
||||
|
||||
# Check if system is already initialized
|
||||
is_initialized = await self.is_initialized()
|
||||
if is_initialized:
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
|
||||
# Create new Space user (first time initialization)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(user.User).values(
|
||||
user=email,
|
||||
password='', # Space users don't have local password
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
|
||||
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
created_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
if created_user is not None:
|
||||
await self._update_space_provider_for_account(created_user, api_key)
|
||||
return created_user
|
||||
|
||||
async def authenticate_space_user(
|
||||
self, access_token: str, refresh_token: str, expires_in: int = 0
|
||||
@@ -221,7 +532,7 @@ class UserService:
|
||||
)
|
||||
|
||||
# Generate JWT token
|
||||
jwt_token = await self.generate_jwt_token(email)
|
||||
jwt_token = await self.generate_jwt_token(user_obj)
|
||||
|
||||
return jwt_token, user_obj
|
||||
|
||||
@@ -247,11 +558,16 @@ class UserService:
|
||||
|
||||
hashed_password = await self._hash_password(new_password)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.normalized_email == normalize_email(user_email))
|
||||
.values(password=hashed_password)
|
||||
)
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: 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)
|
||||
access_token = token_data.get('access_token')
|
||||
@@ -276,15 +592,16 @@ 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.user != user_email:
|
||||
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')
|
||||
|
||||
# Update local account to Space account
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.user == user_email)
|
||||
.where(user.User.normalized_email == normalize_email(user_email))
|
||||
.values(
|
||||
user=space_email, # Update email to Space email
|
||||
user=normalize_email(space_email), # Update email to Space email
|
||||
normalized_email=normalize_email(space_email),
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
@@ -295,6 +612,6 @@ class UserService:
|
||||
)
|
||||
|
||||
# Update Space model provider API keys
|
||||
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
|
||||
await self._update_space_provider_for_account(local_account, api_key)
|
||||
|
||||
return await self.get_user_by_email(space_email)
|
||||
|
||||
@@ -4,6 +4,8 @@ import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import webhook
|
||||
from .secrets import SECRET_MASK, mask_secret_value, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
|
||||
class WebhookService:
|
||||
@@ -12,31 +14,71 @@ class WebhookService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_webhooks(self) -> list[dict]:
|
||||
def _serialize_webhook(self, entity, *, include_secret: bool) -> dict:
|
||||
serialized = self.ap.persistence_mgr.serialize_model(webhook.Webhook, entity)
|
||||
if not include_secret:
|
||||
serialized = serialized.copy()
|
||||
serialized['url'] = mask_secret_value(serialized.get('url'))
|
||||
return serialized
|
||||
|
||||
async def get_webhooks(self, context: TenantContext, *, include_secret: bool = False) -> list[dict]:
|
||||
"""Get all webhooks"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(sqlalchemy.select(webhook.Webhook), webhook.Webhook, context)
|
||||
)
|
||||
|
||||
webhooks = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]
|
||||
return [self._serialize_webhook(wh, include_secret=include_secret) for wh in webhooks]
|
||||
|
||||
async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict:
|
||||
async def create_webhook(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
url: str,
|
||||
description: str = '',
|
||||
enabled: bool = True,
|
||||
) -> dict:
|
||||
"""Create a new webhook"""
|
||||
webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled}
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
url = restore_secret_placeholders(url, sensitive=True)
|
||||
webhook_data = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'name': name,
|
||||
'url': url,
|
||||
'description': description,
|
||||
'enabled': enabled,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data))
|
||||
insert_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(webhook.Webhook).values(**webhook_data)
|
||||
)
|
||||
|
||||
# Retrieve the created webhook
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc())
|
||||
scope_statement(
|
||||
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == insert_result.inserted_primary_key[0]),
|
||||
webhook.Webhook,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
created_webhook = result.first()
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook)
|
||||
|
||||
async def get_webhook(self, webhook_id: int) -> dict | None:
|
||||
async def get_webhook(
|
||||
self,
|
||||
context: TenantContext,
|
||||
webhook_id: int,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> dict | None:
|
||||
"""Get a specific webhook by ID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
|
||||
scope_statement(
|
||||
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id),
|
||||
webhook.Webhook,
|
||||
context,
|
||||
)
|
||||
)
|
||||
|
||||
wh = result.first()
|
||||
@@ -44,16 +86,27 @@ class WebhookService:
|
||||
if wh is None:
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh)
|
||||
return self._serialize_webhook(wh, include_secret=include_secret)
|
||||
|
||||
async def update_webhook(
|
||||
self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None
|
||||
) -> None:
|
||||
self,
|
||||
context: TenantContext,
|
||||
webhook_id: int,
|
||||
name: str | None = None,
|
||||
url: str | None = None,
|
||||
description: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> bool:
|
||||
"""Update a webhook's metadata"""
|
||||
update_data = {}
|
||||
if name is not None:
|
||||
update_data['name'] = name
|
||||
if url is not None:
|
||||
if url == SECRET_MASK:
|
||||
current = await self.get_webhook(context, webhook_id, include_secret=True)
|
||||
if current is None:
|
||||
return False
|
||||
url = restore_secret_placeholders(url, current.get('url'), sensitive=True)
|
||||
update_data['url'] = url
|
||||
if description is not None:
|
||||
update_data['description'] = description
|
||||
@@ -61,20 +114,35 @@ class WebhookService:
|
||||
update_data['enabled'] = enabled
|
||||
|
||||
if update_data:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data),
|
||||
webhook.Webhook,
|
||||
context,
|
||||
)
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
return await self.get_webhook(context, webhook_id) is not None
|
||||
|
||||
async def delete_webhook(self, webhook_id: int) -> None:
|
||||
async def delete_webhook(self, context: TenantContext, webhook_id: int) -> bool:
|
||||
"""Delete a webhook"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id),
|
||||
webhook.Webhook,
|
||||
context,
|
||||
)
|
||||
)
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
async def get_enabled_webhooks(self) -> list[dict]:
|
||||
async def get_enabled_webhooks(self, context: TenantContext) -> list[dict]:
|
||||
"""Get all enabled webhooks"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True)
|
||||
scope_statement(
|
||||
sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True),
|
||||
webhook.Webhook,
|
||||
context,
|
||||
)
|
||||
)
|
||||
|
||||
webhooks = result.all()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
|
||||
from ..http.context import RequestContext
|
||||
|
||||
|
||||
_request_context: contextvars.ContextVar[RequestContext | None] = contextvars.ContextVar(
|
||||
'langbot_mcp_request_context',
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def bind_request_context(context: RequestContext) -> contextvars.Token[RequestContext | None]:
|
||||
"""Bind the authenticated MCP request while its ASGI request is executing."""
|
||||
|
||||
return _request_context.set(context)
|
||||
|
||||
|
||||
def reset_request_context(token: contextvars.Token[RequestContext | None]) -> None:
|
||||
_request_context.reset(token)
|
||||
|
||||
|
||||
def get_request_context() -> RequestContext:
|
||||
"""Return the current trusted MCP context or fail closed."""
|
||||
|
||||
context = _request_context.get()
|
||||
if context is None:
|
||||
raise RuntimeError('MCP Workspace context is unavailable')
|
||||
return context
|
||||
@@ -19,7 +19,10 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from .context import bind_request_context, reset_request_context
|
||||
from .server import LangBotMCPServer
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -76,7 +79,7 @@ class MCPMount:
|
||||
def wrap(self, quart_asgi: typing.Callable) -> typing.Callable:
|
||||
"""Return a dispatcher ASGI app fronting ``quart_asgi``."""
|
||||
mcp_asgi = self._mcp_asgi
|
||||
verify_api_key = self.ap.apikey_service.verify_api_key
|
||||
authenticate_api_key = self.ap.apikey_service.authenticate_api_key
|
||||
is_mcp_path = self._is_mcp_path
|
||||
|
||||
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
|
||||
@@ -88,12 +91,12 @@ class MCPMount:
|
||||
|
||||
# Authenticate MCP HTTP requests with a LangBot API key.
|
||||
api_key = _extract_api_key(scope.get('headers', []))
|
||||
authorized = False
|
||||
identity = None
|
||||
if api_key:
|
||||
with contextlib.suppress(Exception):
|
||||
authorized = await verify_api_key(api_key)
|
||||
identity = await authenticate_api_key(api_key)
|
||||
|
||||
if not authorized:
|
||||
if identity is None:
|
||||
await send(
|
||||
{
|
||||
'type': 'http.response.start',
|
||||
@@ -107,6 +110,26 @@ class MCPMount:
|
||||
await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY})
|
||||
return
|
||||
|
||||
await mcp_asgi(scope, receive, send)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=identity.instance_uuid,
|
||||
placement_generation=identity.placement_generation,
|
||||
request_id=str(uuid.uuid4()),
|
||||
auth_type='api-key',
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.API_KEY,
|
||||
api_key_uuid=identity.api_key_uuid,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=identity.workspace_uuid,
|
||||
membership_uuid=None,
|
||||
role=None,
|
||||
permissions=identity.permissions,
|
||||
),
|
||||
)
|
||||
token = bind_request_context(request_context)
|
||||
try:
|
||||
await mcp_asgi(scope, receive, send)
|
||||
finally:
|
||||
reset_request_context(token)
|
||||
|
||||
return dispatcher
|
||||
|
||||
@@ -22,6 +22,9 @@ import typing
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ..http.authz import Permission, require_permission
|
||||
from .context import get_request_context
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ...core import app as app_module
|
||||
|
||||
@@ -46,6 +49,12 @@ def _dump(value: typing.Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _authorized(permission: Permission):
|
||||
context = get_request_context()
|
||||
require_permission(context, permission)
|
||||
return context
|
||||
|
||||
|
||||
class LangBotMCPServer:
|
||||
"""Builds and owns the FastMCP instance for LangBot."""
|
||||
|
||||
@@ -72,6 +81,7 @@ class LangBotMCPServer:
|
||||
# ----- System (read-only) -------------------------------------- #
|
||||
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
|
||||
async def get_system_info() -> str:
|
||||
_authorized(Permission.WORKSPACE_VIEW)
|
||||
version = None
|
||||
try:
|
||||
version = ap.ver_mgr.get_current_version()
|
||||
@@ -87,11 +97,13 @@ class LangBotMCPServer:
|
||||
# ----- Bots ---------------------------------------------------- #
|
||||
@mcp.tool(description='List all messaging-platform bots. Secrets are redacted.')
|
||||
async def list_bots() -> str:
|
||||
return _dump(await ap.bot_service.get_bots(include_secret=False))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.bot_service.get_bots(context, include_secret=False))
|
||||
|
||||
@mcp.tool(description='Get a single bot by its UUID. Secrets are redacted.')
|
||||
async def get_bot(bot_uuid: str) -> str:
|
||||
return _dump(await ap.bot_service.get_bot(bot_uuid, include_secret=False))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.bot_service.get_bot(context, bot_uuid, include_secret=False))
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
@@ -101,26 +113,31 @@ class LangBotMCPServer:
|
||||
)
|
||||
)
|
||||
async def create_bot(bot_data: dict) -> str:
|
||||
return _dump({'uuid': await ap.bot_service.create_bot(bot_data)})
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)})
|
||||
|
||||
@mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.')
|
||||
async def update_bot(bot_uuid: str, bot_data: dict) -> str:
|
||||
await ap.bot_service.update_bot(bot_uuid, bot_data)
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
await ap.bot_service.update_bot(context, bot_uuid, bot_data)
|
||||
return _dump({'ok': True})
|
||||
|
||||
@mcp.tool(description='Delete a bot by UUID.')
|
||||
async def delete_bot(bot_uuid: str) -> str:
|
||||
await ap.bot_service.delete_bot(bot_uuid)
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
await ap.bot_service.delete_bot(context, bot_uuid)
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- Pipelines ----------------------------------------------- #
|
||||
@mcp.tool(description='List all pipelines.')
|
||||
async def list_pipelines() -> str:
|
||||
return _dump(await ap.pipeline_service.get_pipelines())
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.pipeline_service.get_pipelines(context))
|
||||
|
||||
@mcp.tool(description='Get a single pipeline by UUID.')
|
||||
async def get_pipeline(pipeline_uuid: str) -> str:
|
||||
return _dump(await ap.pipeline_service.get_pipeline(pipeline_uuid))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.pipeline_service.get_pipeline(context, pipeline_uuid))
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
@@ -129,49 +146,59 @@ class LangBotMCPServer:
|
||||
)
|
||||
)
|
||||
async def create_pipeline(pipeline_data: dict) -> str:
|
||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(pipeline_data)})
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
||||
|
||||
@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:
|
||||
await ap.pipeline_service.update_pipeline(pipeline_uuid, pipeline_data)
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
await ap.pipeline_service.update_pipeline(context, pipeline_uuid, pipeline_data)
|
||||
return _dump({'ok': True})
|
||||
|
||||
@mcp.tool(description='Delete a pipeline by UUID.')
|
||||
async def delete_pipeline(pipeline_uuid: str) -> str:
|
||||
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
await ap.pipeline_service.delete_pipeline(context, pipeline_uuid)
|
||||
return _dump({'ok': True})
|
||||
|
||||
# ----- Models -------------------------------------------------- #
|
||||
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
|
||||
async def list_llm_models() -> str:
|
||||
return _dump(await ap.llm_model_service.get_llm_models(include_secret=False))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False))
|
||||
|
||||
@mcp.tool(description='Get a single LLM model by UUID.')
|
||||
async def get_llm_model(model_uuid: str) -> str:
|
||||
return _dump(await ap.llm_model_service.get_llm_model(model_uuid))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False))
|
||||
|
||||
@mcp.tool(description='List all configured embedding models.')
|
||||
async def list_embedding_models() -> str:
|
||||
return _dump(await ap.embedding_models_service.get_embedding_models())
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.embedding_models_service.get_embedding_models(context, include_secret=False))
|
||||
|
||||
@mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).')
|
||||
async def list_model_providers() -> str:
|
||||
return _dump(await ap.provider_service.get_providers())
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.provider_service.get_providers(context, include_secret=False))
|
||||
|
||||
# ----- Knowledge bases ----------------------------------------- #
|
||||
@mcp.tool(description='List all knowledge bases (RAG).')
|
||||
async def list_knowledge_bases() -> str:
|
||||
return _dump(await ap.knowledge_service.get_knowledge_bases())
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.knowledge_service.get_knowledge_bases(context))
|
||||
|
||||
@mcp.tool(description='Get a single knowledge base by UUID.')
|
||||
async def get_knowledge_base(kb_uuid: str) -> str:
|
||||
return _dump(await ap.knowledge_service.get_knowledge_base(kb_uuid))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.knowledge_service.get_knowledge_base(context, kb_uuid))
|
||||
|
||||
@mcp.tool(
|
||||
description=('Retrieve (semantic search) from a knowledge base. Returns the matched chunks for `query`.')
|
||||
)
|
||||
async def retrieve_knowledge_base(kb_uuid: str, query: str) -> str:
|
||||
return _dump(await ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.knowledge_service.retrieve_knowledge_base(context, kb_uuid, query))
|
||||
|
||||
# ----- MCP servers (LangBot as MCP client) --------------------- #
|
||||
@mcp.tool(
|
||||
@@ -180,16 +207,19 @@ class LangBotMCPServer:
|
||||
)
|
||||
)
|
||||
async def list_mcp_servers() -> str:
|
||||
return _dump(await ap.mcp_service.get_mcp_servers())
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.mcp_service.get_mcp_servers(context))
|
||||
|
||||
# ----- Skills -------------------------------------------------- #
|
||||
@mcp.tool(description='List installed skills.')
|
||||
async def list_skills() -> str:
|
||||
return _dump(await ap.skill_service.list_skills())
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.skill_service.list_skills(context))
|
||||
|
||||
@mcp.tool(description='Get a single skill by name.')
|
||||
async def get_skill(skill_name: str) -> str:
|
||||
return _dump(await ap.skill_service.get_skill(skill_name))
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.skill_service.get_skill(context, skill_name))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ASGI app
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import typing
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -16,6 +17,17 @@ from langbot_plugin.runtime.io.connection import Connection
|
||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
|
||||
from langbot_plugin.box.actions import LangBotToBoxAction
|
||||
from langbot_plugin.box.security import (
|
||||
BOX_CONTROL_TOKEN_ENV,
|
||||
BOX_CONTROL_TOKEN_HEADER,
|
||||
BOX_INSTANCE_HEADER,
|
||||
BOX_PLACEMENT_GENERATION_HEADER,
|
||||
BOX_TRUSTED_INSTANCE_ENV,
|
||||
BOX_WORKSPACE_HEADER,
|
||||
normalize_instance_uuid,
|
||||
validate_control_token,
|
||||
)
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
|
||||
from ..utils import platform
|
||||
from ..utils.managed_runtime import ManagedRuntimeConnector
|
||||
@@ -123,6 +135,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
self._relay_host = parsed.hostname or '127.0.0.1'
|
||||
self._relay_port = parsed.port or _DEFAULT_PORT
|
||||
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
|
||||
self._trusted_instance_uuid = normalize_instance_uuid(self.ap.workspace_service.instance_uuid)
|
||||
self._control_token = str(os.environ.get(BOX_CONTROL_TOKEN_ENV) or '').strip()
|
||||
|
||||
def uses_websocket(self) -> bool:
|
||||
"""Whether the connector should use WebSocket to reach the Box runtime.
|
||||
@@ -223,8 +237,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController
|
||||
|
||||
self.ap.logger.info('Use stdio to connect to box runtime')
|
||||
self._ensure_control_token(allow_generate=True)
|
||||
python_path = sys.executable
|
||||
env = os.environ.copy()
|
||||
env[BOX_CONTROL_TOKEN_ENV] = self._control_token
|
||||
env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
|
||||
if self._filtered_box_config:
|
||||
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
|
||||
|
||||
@@ -259,7 +276,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
|
||||
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
|
||||
|
||||
self._ensure_control_token(allow_generate=True)
|
||||
env = os.environ.copy()
|
||||
env[BOX_CONTROL_TOKEN_ENV] = self._control_token
|
||||
env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
|
||||
if self._filtered_box_config:
|
||||
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
|
||||
|
||||
@@ -282,6 +302,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
async def _connect_remote_ws(self) -> None:
|
||||
"""Connect to a remote (or Docker) box server via WebSocket."""
|
||||
self._ensure_control_token(allow_generate=False)
|
||||
ws_url = self._resolve_rpc_ws_url()
|
||||
self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})')
|
||||
await self._connect_ws(ws_url, 'WebSocket')
|
||||
@@ -325,8 +346,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
if self.runtime_disconnect_callback is not None:
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
|
||||
self._ctrl = ctrl
|
||||
ctrl = WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=on_connect_failed,
|
||||
additional_headers=self.get_control_headers(),
|
||||
)
|
||||
self._ctrl_task = asyncio.create_task(
|
||||
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error, self._generation))
|
||||
)
|
||||
@@ -339,6 +363,41 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
if connect_error:
|
||||
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
|
||||
|
||||
def _ensure_control_token(self, *, allow_generate: bool) -> str:
|
||||
if not self._control_token and allow_generate:
|
||||
self._control_token = secrets.token_urlsafe(48)
|
||||
try:
|
||||
self._control_token = validate_control_token(self._control_token)
|
||||
except ValueError as exc:
|
||||
raise BoxRuntimeUnavailableError(
|
||||
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for an external Box runtime'
|
||||
) from exc
|
||||
return self._control_token
|
||||
|
||||
def get_control_headers(self) -> dict[str, str]:
|
||||
"""Headers for the instance-authenticated RPC control handshake."""
|
||||
|
||||
self._ensure_control_token(allow_generate=False)
|
||||
return {
|
||||
BOX_CONTROL_TOKEN_HEADER: self._control_token,
|
||||
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
|
||||
}
|
||||
|
||||
def get_relay_headers(
|
||||
self,
|
||||
action_context: ActionContext,
|
||||
) -> dict[str, str]:
|
||||
"""Return authenticated, placement-scoped relay handshake headers."""
|
||||
|
||||
context = ActionContext.model_validate(action_context).without_installation()
|
||||
if context.instance_uuid != self._trusted_instance_uuid:
|
||||
raise BoxRuntimeUnavailableError('Box relay context belongs to another LangBot instance')
|
||||
return {
|
||||
**self.get_control_headers(),
|
||||
BOX_WORKSPACE_HEADER: context.workspace_uuid,
|
||||
BOX_PLACEMENT_GENERATION_HEADER: str(context.placement_generation),
|
||||
}
|
||||
|
||||
def _make_connection_callback(
|
||||
self,
|
||||
transport_name: str,
|
||||
|
||||
+320
-67
@@ -12,8 +12,12 @@ from typing import TYPE_CHECKING
|
||||
import pydantic
|
||||
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.box.tenancy import box_namespace
|
||||
from .connector import BoxRuntimeConnector, _get_box_config
|
||||
from ..telemetry import features as telemetry_features
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from langbot_plugin.box.errors import BoxError, BoxValidationError
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
@@ -191,6 +195,69 @@ class BoxService:
|
||||
return False
|
||||
return not self._runtime_connector.uses_websocket()
|
||||
|
||||
@staticmethod
|
||||
def _execution_context(context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid:
|
||||
raise BoxValidationError('Box operations require an explicit instance UUID')
|
||||
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
||||
raise BoxValidationError('Box operations require a positive placement generation')
|
||||
return ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _query_execution_context(cls, query: pipeline_query.Query) -> ExecutionContext:
|
||||
return cls._execution_context(
|
||||
ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
placement_generation=getattr(query, 'placement_generation', 0) or 0,
|
||||
bot_uuid=getattr(query, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(query, 'query_uuid', None),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _action_context(cls, context: TenantContext) -> ActionContext:
|
||||
execution_context = cls._execution_context(context)
|
||||
return ActionContext(
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
)
|
||||
|
||||
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
"""Resolve and fence a tenant context before touching shared Box state."""
|
||||
|
||||
execution_context = self._execution_context(context)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise BoxValidationError('Box execution context belongs to another LangBot instance')
|
||||
if (
|
||||
str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
|
||||
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
|
||||
):
|
||||
raise BoxValidationError('Box execution context belongs to a stale Workspace placement')
|
||||
return execution_context
|
||||
|
||||
def _tenant_workspace(self, context: TenantContext) -> str | None:
|
||||
if self.default_workspace is None:
|
||||
return None
|
||||
namespace = box_namespace(self._action_context(context))
|
||||
return os.path.join(self.default_workspace, 'tenants', namespace)
|
||||
|
||||
async def execute_spec_payload(
|
||||
self,
|
||||
spec_payload: dict,
|
||||
@@ -200,6 +267,14 @@ class BoxService:
|
||||
) -> dict:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = dict(spec_payload)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
if tenant_workspace is not None:
|
||||
spec_payload['host_path'] = tenant_workspace
|
||||
if self.shares_filesystem_with_box:
|
||||
os.makedirs(tenant_workspace, exist_ok=True)
|
||||
try:
|
||||
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||
except BoxError as exc:
|
||||
@@ -216,14 +291,17 @@ class BoxService:
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
try:
|
||||
result = await self.client.execute(spec)
|
||||
result = await self.client.execute(
|
||||
spec,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
except BoxError as exc:
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
try:
|
||||
await self._enforce_workspace_quota(spec, phase='after execution')
|
||||
except BoxError as exc:
|
||||
await self._cleanup_exceeded_session(spec)
|
||||
await self._cleanup_exceeded_session(execution_context, spec)
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
self.ap.logger.info(
|
||||
@@ -347,6 +425,26 @@ class BoxService:
|
||||
|
||||
return await self.execute_spec_payload(spec_payload, query)
|
||||
|
||||
async def execute_in_context(
|
||||
self,
|
||||
context: TenantContext,
|
||||
spec_payload: dict,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
) -> BoxExecutionResult:
|
||||
"""Execute trusted internal Box work inside one Workspace namespace."""
|
||||
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
payload = dict(spec_payload)
|
||||
if payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
if tenant_workspace is not None:
|
||||
payload['host_path'] = tenant_workspace
|
||||
if self.shares_filesystem_with_box:
|
||||
os.makedirs(tenant_workspace, exist_ok=True)
|
||||
spec = self.build_spec(payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||
return await self.client.execute(spec, action_context=self._action_context(execution_context))
|
||||
|
||||
# ── Attachment passthrough (inbound / outbound) ──────────────────
|
||||
#
|
||||
# IM/webchat attachments (images, voices, files) reach the LLM as
|
||||
@@ -379,7 +477,7 @@ class BoxService:
|
||||
# truncation). The host-filesystem path has no such limit.
|
||||
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
|
||||
|
||||
def _host_query_dir(self, subdir: str, query_id) -> str | None:
|
||||
def _host_query_dir(self, subdir: str, query: pipeline_query.Query) -> str | None:
|
||||
"""Host path for ``/workspace/<subdir>/<query_id>`` when LangBot can
|
||||
access the bind-mounted workspace directly, else ``None``.
|
||||
|
||||
@@ -389,10 +487,10 @@ class BoxService:
|
||||
to the sandbox (and vice-versa). It is ``None`` / not a local dir for
|
||||
E2B and remote runtimes, where we must fall back to the exec channel.
|
||||
"""
|
||||
root = self.default_workspace
|
||||
root = self._tenant_workspace(self._query_execution_context(query))
|
||||
if not root or not os.path.isdir(root):
|
||||
return None
|
||||
return os.path.join(root, subdir, str(query_id))
|
||||
return os.path.join(root, subdir, str(query.query_id))
|
||||
|
||||
async def _purge_attachment_dirs(self) -> None:
|
||||
"""Remove leftover inbox/outbox directories on startup.
|
||||
@@ -402,12 +500,10 @@ class BoxService:
|
||||
a previous process would otherwise be silently reused — leaking a prior
|
||||
run's inbound files and re-sending stale outbound files.
|
||||
|
||||
Outbox files are written by the sandbox **container**, which runs as
|
||||
root over the bind-mount, so the LangBot host process (a non-root user)
|
||||
cannot ``rmtree`` them. We therefore try a host-side delete first (fast,
|
||||
works for host-owned inbox files) and, for anything that survives,
|
||||
delete from *inside* the sandbox via exec where the container's root can
|
||||
remove its own files. Best-effort: never block startup.
|
||||
Tenant workspaces live below ``default_workspace/tenants``. Startup has
|
||||
no authenticated Workspace context, so cleanup is deliberately limited
|
||||
to direct host-filesystem deletion. It must never issue an unscoped Box
|
||||
exec merely to remove root-owned container output.
|
||||
"""
|
||||
root = self.default_workspace
|
||||
if not root or not os.path.isdir(root):
|
||||
@@ -418,14 +514,29 @@ class BoxService:
|
||||
host_survivors: list[str] = []
|
||||
|
||||
def _host_purge() -> list[str]:
|
||||
candidates = [
|
||||
os.path.join(root, self.INBOX_SUBDIR),
|
||||
os.path.join(root, self.OUTBOX_SUBDIR),
|
||||
]
|
||||
tenants_root = os.path.join(root, 'tenants')
|
||||
if os.path.isdir(tenants_root):
|
||||
with os.scandir(tenants_root) as tenant_entries:
|
||||
for tenant_entry in tenant_entries:
|
||||
if not tenant_entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
candidates.extend(
|
||||
[
|
||||
os.path.join(tenant_entry.path, self.INBOX_SUBDIR),
|
||||
os.path.join(tenant_entry.path, self.OUTBOX_SUBDIR),
|
||||
]
|
||||
)
|
||||
survivors: list[str] = []
|
||||
for subdir in (self.INBOX_SUBDIR, self.OUTBOX_SUBDIR):
|
||||
path = os.path.join(root, subdir)
|
||||
for path in candidates:
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.exists(path):
|
||||
survivors.append(subdir)
|
||||
survivors.append(path)
|
||||
return survivors
|
||||
|
||||
try:
|
||||
@@ -438,18 +549,11 @@ class BoxService:
|
||||
self.ap.logger.info('Purged leftover sandbox attachment dirs from a previous process.')
|
||||
return
|
||||
|
||||
# Root-owned leftovers (container output): delete from inside the box.
|
||||
targets = ' '.join(f'/workspace/{sub}' for sub in host_survivors)
|
||||
try:
|
||||
spec = self.build_spec({'cmd': f'rm -rf {targets}', 'session_id': '__startup_purge__', 'timeout_sec': 30})
|
||||
await self.client.execute(spec)
|
||||
self.ap.logger.info(
|
||||
f'Purged root-owned leftover sandbox attachment dirs via sandbox exec: {host_survivors}'
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to purge root-owned sandbox attachment dirs {host_survivors} via exec: {exc}'
|
||||
)
|
||||
self.ap.logger.warning(
|
||||
'Could not purge root-owned sandbox attachment directories from the host; '
|
||||
'skipping an unsafe unscoped Box exec because startup has no trusted '
|
||||
f'Workspace context: {host_survivors}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_attachment_name(name: str, fallback: str) -> str:
|
||||
@@ -529,7 +633,7 @@ class BoxService:
|
||||
if not files:
|
||||
return []
|
||||
|
||||
host_dir = self._host_query_dir(subdir, query.query_id)
|
||||
host_dir = self._host_query_dir(subdir, query)
|
||||
if host_dir is not None:
|
||||
return await asyncio.to_thread(self._write_files_host, host_dir, target_mount_dir, files)
|
||||
|
||||
@@ -702,7 +806,7 @@ class BoxService:
|
||||
if not self._available:
|
||||
return []
|
||||
|
||||
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query.query_id)
|
||||
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
|
||||
if host_dir is not None:
|
||||
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
|
||||
else:
|
||||
@@ -872,11 +976,12 @@ class BoxService:
|
||||
elif self._runtime_connector is not None:
|
||||
self._runtime_connector.dispose()
|
||||
|
||||
async def get_sessions(self) -> list[dict]:
|
||||
async def get_sessions(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
if not self._available:
|
||||
return []
|
||||
try:
|
||||
return await self.client.get_sessions()
|
||||
return await self.client.get_sessions(action_context=self._action_context(execution_context))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -904,21 +1009,70 @@ class BoxService:
|
||||
self._validate_host_mount(spec)
|
||||
return spec
|
||||
|
||||
async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict:
|
||||
async def create_session(
|
||||
self,
|
||||
context: TenantContext,
|
||||
spec_payload: dict,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
spec_payload = dict(spec_payload)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
if tenant_workspace is not None:
|
||||
spec_payload['host_path'] = tenant_workspace
|
||||
if self.shares_filesystem_with_box:
|
||||
os.makedirs(tenant_workspace, exist_ok=True)
|
||||
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||
return await self.client.create_session(spec)
|
||||
return await self.client.create_session(spec, action_context=self._action_context(execution_context))
|
||||
|
||||
async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo:
|
||||
async def start_managed_process(
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
process_payload: dict,
|
||||
) -> BoxManagedProcessInfo:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
|
||||
return await self.client.start_managed_process(session_id, process_spec)
|
||||
return await self.client.start_managed_process(
|
||||
session_id,
|
||||
process_spec,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
|
||||
return await self.client.get_managed_process(session_id, process_id)
|
||||
async def get_managed_process(
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
) -> BoxManagedProcessInfo:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.get_managed_process(
|
||||
session_id,
|
||||
process_id,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None:
|
||||
return await self.client.stop_managed_process(session_id, process_id)
|
||||
async def stop_managed_process(
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
) -> None:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.stop_managed_process(
|
||||
session_id,
|
||||
process_id,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str:
|
||||
def _get_managed_process_websocket_url(
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
) -> str:
|
||||
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
|
||||
if getter is None:
|
||||
raise BoxValidationError('box runtime client does not support managed process websocket attach')
|
||||
@@ -927,52 +1081,127 @@ class BoxService:
|
||||
if self._runtime_connector is not None
|
||||
else 'http://127.0.0.1:5410'
|
||||
)
|
||||
return getter(session_id, ws_relay_base_url, process_id)
|
||||
return getter(
|
||||
session_id,
|
||||
ws_relay_base_url,
|
||||
process_id,
|
||||
action_context=self._action_context(context),
|
||||
)
|
||||
|
||||
async def list_skills(self) -> list[dict]:
|
||||
return await self.client.list_skills()
|
||||
async def get_managed_process_websocket_connection(
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Resolve a relay URL and headers after fencing the placement.
|
||||
|
||||
async def get_skill(self, name: str) -> dict | None:
|
||||
return await self.client.get_skill(name)
|
||||
The shared Box control secret is transported only in headers. The
|
||||
Workspace and generation headers bind the relay to the same trusted
|
||||
execution context used by the action RPC that created the process.
|
||||
"""
|
||||
|
||||
async def create_skill(self, skill: dict) -> dict:
|
||||
return await self.client.create_skill(skill)
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
if self._runtime_connector is None:
|
||||
raise BoxValidationError(
|
||||
'box runtime connector does not support authenticated managed process websocket attach'
|
||||
)
|
||||
action_context = self._action_context(execution_context)
|
||||
return (
|
||||
self._get_managed_process_websocket_url(
|
||||
execution_context,
|
||||
session_id,
|
||||
process_id,
|
||||
),
|
||||
self._runtime_connector.get_relay_headers(action_context),
|
||||
)
|
||||
|
||||
async def update_skill(self, name: str, skill: dict) -> dict:
|
||||
return await self.client.update_skill(name, skill)
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.list_skills(action_context=self._action_context(execution_context))
|
||||
|
||||
async def delete_skill(self, name: str) -> None:
|
||||
await self.client.delete_skill(name)
|
||||
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def scan_skill_directory(self, path: str) -> dict:
|
||||
return await self.client.scan_skill_directory(path)
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', 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_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', 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_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_execution_context(context)
|
||||
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
return await self.client.list_skill_files(name, path, include_hidden, max_entries)
|
||||
execution_context = await self._validated_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, name: str, path: str) -> dict:
|
||||
return await self.client.read_skill_file(name, path)
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
execution_context = await self._validated_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, name: str, path: str, content: str) -> dict:
|
||||
return await self.client.write_skill_file(name, path, content)
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._validated_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]:
|
||||
return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix)
|
||||
execution_context = await self._validated_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,
|
||||
@@ -980,6 +1209,7 @@ class BoxService:
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.install_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
@@ -987,6 +1217,7 @@ class BoxService:
|
||||
source_path,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||
@@ -1305,9 +1536,12 @@ class BoxService:
|
||||
f'host_path={host_path} session_id={spec.session_id}'
|
||||
)
|
||||
|
||||
async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None:
|
||||
async def _cleanup_exceeded_session(self, context: TenantContext, spec: BoxSpec) -> None:
|
||||
try:
|
||||
await self.client.delete_session(spec.session_id)
|
||||
await self.client.delete_session(
|
||||
spec.session_id,
|
||||
action_context=self._action_context(context),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
'Failed to clean up Box session after workspace quota was exceeded: '
|
||||
@@ -1324,11 +1558,19 @@ class BoxService:
|
||||
'type': type(exc).__name__,
|
||||
'message': str(exc),
|
||||
'query_id': str(query.query_id),
|
||||
'instance_uuid': str(getattr(query, 'instance_uuid', '') or ''),
|
||||
'workspace_uuid': str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
}
|
||||
)
|
||||
|
||||
def get_recent_errors(self) -> list[dict]:
|
||||
return list(self._recent_errors)
|
||||
def get_recent_errors(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = self._execution_context(context)
|
||||
return [
|
||||
error
|
||||
for error in self._recent_errors
|
||||
if error.get('instance_uuid') == execution_context.instance_uuid
|
||||
and error.get('workspace_uuid') == execution_context.workspace_uuid
|
||||
]
|
||||
|
||||
def get_system_guidance(self, query_id=None) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
@@ -1366,17 +1608,28 @@ class BoxService:
|
||||
)
|
||||
return guidance
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
async def get_backend_status(self) -> dict:
|
||||
"""Return instance-level backend readiness without tenant resource data."""
|
||||
|
||||
if not self._available:
|
||||
return {'available': False, 'enabled': self._enabled, 'connector_error': self._connector_error}
|
||||
backend = await self.client.get_backend_info()
|
||||
return {'available': bool(backend.get('available', False)), 'enabled': self._enabled, 'backend': backend}
|
||||
|
||||
async def get_status(self, context: TenantContext) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
action_context = self._action_context(execution_context)
|
||||
recent_error_count = len(self.get_recent_errors(execution_context))
|
||||
if not self._available:
|
||||
return {
|
||||
'available': False,
|
||||
'enabled': self._enabled,
|
||||
'profile': self.profile.name,
|
||||
'recent_error_count': len(self._recent_errors),
|
||||
'recent_error_count': recent_error_count,
|
||||
'connector_error': self._connector_error,
|
||||
}
|
||||
try:
|
||||
runtime_status = await self.client.get_status()
|
||||
runtime_status = await self.client.get_status(action_context=action_context)
|
||||
except Exception as exc:
|
||||
# RPC failed — the runtime likely just disconnected and the
|
||||
# heartbeat hasn't flipped _available yet.
|
||||
@@ -1384,7 +1637,7 @@ class BoxService:
|
||||
'available': False,
|
||||
'enabled': self._enabled,
|
||||
'profile': self.profile.name,
|
||||
'recent_error_count': len(self._recent_errors),
|
||||
'recent_error_count': recent_error_count,
|
||||
'connector_error': str(exc),
|
||||
}
|
||||
# Backend state can be unavailable even when the connector is healthy
|
||||
@@ -1402,7 +1655,7 @@ class BoxService:
|
||||
'available': backend_ok,
|
||||
'enabled': self._enabled,
|
||||
'profile': self.profile.name,
|
||||
'recent_error_count': len(self._recent_errors),
|
||||
'recent_error_count': recent_error_count,
|
||||
}
|
||||
if not backend_ok and 'connector_error' not in payload:
|
||||
backend_name = backend_info.get('name') if backend_info else None
|
||||
|
||||
@@ -274,6 +274,7 @@ class BoxWorkspaceSession:
|
||||
def __init__(
|
||||
self,
|
||||
box_service,
|
||||
execution_context,
|
||||
session_id: str,
|
||||
*,
|
||||
host_path: str | None = None,
|
||||
@@ -290,6 +291,7 @@ class BoxWorkspaceSession:
|
||||
persistent: bool = False,
|
||||
):
|
||||
self.box_service = box_service
|
||||
self.execution_context = execution_context
|
||||
self.session_id = session_id
|
||||
self.host_path = host_path
|
||||
self.host_path_mode = host_path_mode
|
||||
@@ -363,7 +365,7 @@ class BoxWorkspaceSession:
|
||||
timeout_sec: int | None = None,
|
||||
):
|
||||
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
|
||||
return await self.box_service.client.execute(self.box_service.build_spec(payload))
|
||||
return await self.box_service.execute_in_context(self.execution_context, payload)
|
||||
|
||||
async def execute_for_query(
|
||||
self,
|
||||
@@ -378,7 +380,7 @@ class BoxWorkspaceSession:
|
||||
return await self.box_service.execute_spec_payload(payload, query)
|
||||
|
||||
async def create_session(self):
|
||||
return await self.box_service.create_session(self.build_session_payload())
|
||||
return await self.box_service.create_session(self.execution_context, self.build_session_payload())
|
||||
|
||||
def build_process_payload(
|
||||
self,
|
||||
@@ -415,16 +417,26 @@ class BoxWorkspaceSession:
|
||||
):
|
||||
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
|
||||
payload['process_id'] = process_id
|
||||
return await self.box_service.start_managed_process(self.session_id, payload)
|
||||
return await self.box_service.start_managed_process(self.execution_context, self.session_id, payload)
|
||||
|
||||
async def get_managed_process(self, process_id: str = 'default'):
|
||||
return await self.box_service.get_managed_process(self.session_id, process_id)
|
||||
return await self.box_service.get_managed_process(self.execution_context, self.session_id, process_id)
|
||||
|
||||
async def stop_managed_process(self, process_id: str = 'default') -> None:
|
||||
await self.box_service.stop_managed_process(self.session_id, process_id)
|
||||
await self.box_service.stop_managed_process(self.execution_context, self.session_id, process_id)
|
||||
|
||||
def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
|
||||
return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
|
||||
async def get_managed_process_websocket_connection(
|
||||
self,
|
||||
process_id: str = 'default',
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
return await self.box_service.get_managed_process_websocket_connection(
|
||||
self.execution_context,
|
||||
self.session_id,
|
||||
process_id,
|
||||
)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
await self.box_service.client.delete_session(self.session_id)
|
||||
await self.box_service.client.delete_session(
|
||||
self.session_id,
|
||||
action_context=self.box_service._action_context(self.execution_context),
|
||||
)
|
||||
|
||||
@@ -89,6 +89,7 @@ class CommandManager:
|
||||
|
||||
_admins = await self.ap.persistence_mgr.execute_async(
|
||||
_sa.select(_BotAdmin).where(
|
||||
_BotAdmin.workspace_uuid == query.workspace_uuid,
|
||||
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
|
||||
_BotAdmin.launcher_type == query.launcher_type.value,
|
||||
_BotAdmin.launcher_id == str(query.launcher_id),
|
||||
@@ -98,7 +99,11 @@ class CommandManager:
|
||||
privilege = 2
|
||||
|
||||
ctx = command_context.ExecuteContext(
|
||||
instance_uuid=query.instance_uuid,
|
||||
workspace_uuid=query.workspace_uuid,
|
||||
placement_generation=query.placement_generation,
|
||||
query_id=query.query_id,
|
||||
query_uuid=query.query_uuid,
|
||||
session=session,
|
||||
command_text=command_text,
|
||||
full_command_text=full_command_text,
|
||||
|
||||
+54
-14
@@ -4,7 +4,7 @@ import logging
|
||||
import asyncio
|
||||
import traceback
|
||||
import os
|
||||
import contextlib
|
||||
import sqlalchemy
|
||||
|
||||
from ..platform import botmgr as im_mgr
|
||||
from ..platform.webhook_pusher import WebhookPusher
|
||||
@@ -46,6 +46,10 @@ from ..vector import mgr as vectordb_mgr
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
from ..workspace import service as workspace_service_module
|
||||
from ..workspace import collaboration as workspace_collaboration_module
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ..entity.persistence.workspace import WorkspaceExecutionState, WorkspaceExecutionStatus
|
||||
|
||||
|
||||
class Application:
|
||||
@@ -120,6 +124,10 @@ class Application:
|
||||
|
||||
persistence_mgr: persistencemgr.PersistenceManager = None
|
||||
|
||||
workspace_service: workspace_service_module.WorkspaceService = None
|
||||
|
||||
workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
|
||||
|
||||
vector_db_mgr: vectordb_mgr.VectorDBManager = None
|
||||
|
||||
http_ctrl: http_controller.HTTPController = None
|
||||
@@ -237,16 +245,31 @@ class Application:
|
||||
check_interval_seconds = check_interval_hours * 3600
|
||||
while True:
|
||||
try:
|
||||
deleted = await self.monitoring_service.cleanup_expired_records(
|
||||
retention_days,
|
||||
batch_size=delete_batch_size,
|
||||
)
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
|
||||
f'(retention={retention_days}d): {deleted}'
|
||||
execution_states = await self.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(WorkspaceExecutionState).where(
|
||||
WorkspaceExecutionState.instance_uuid == self.workspace_service.instance_uuid,
|
||||
WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
|
||||
WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
|
||||
)
|
||||
)
|
||||
for execution_state in execution_states.all():
|
||||
context = ExecutionContext(
|
||||
instance_uuid=execution_state.instance_uuid,
|
||||
workspace_uuid=execution_state.workspace_uuid,
|
||||
placement_generation=execution_state.active_generation,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
)
|
||||
deleted = await self.monitoring_service.cleanup_expired_records(
|
||||
context,
|
||||
retention_days,
|
||||
batch_size=delete_batch_size,
|
||||
)
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
|
||||
f'for Workspace {context.workspace_uuid} (retention={retention_days}d): {deleted}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
@@ -270,10 +293,27 @@ class Application:
|
||||
check_interval_seconds = check_interval_hours * 3600
|
||||
while True:
|
||||
try:
|
||||
deleted = await self.maintenance_service.cleanup_expired_files()
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
|
||||
execution_states = await self.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(WorkspaceExecutionState).where(
|
||||
WorkspaceExecutionState.instance_uuid == self.workspace_service.instance_uuid,
|
||||
WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
|
||||
WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
|
||||
)
|
||||
)
|
||||
for execution_state in execution_states.all():
|
||||
context = ExecutionContext(
|
||||
instance_uuid=execution_state.instance_uuid,
|
||||
workspace_uuid=execution_state.workspace_uuid,
|
||||
placement_generation=execution_state.active_generation,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
)
|
||||
deleted = await self.maintenance_service.cleanup_expired_files(context)
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
f'Storage maintenance for Workspace {context.workspace_uuid}: '
|
||||
f'deleted expired files: {deleted}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Storage maintenance error: {e}')
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
@@ -37,6 +37,11 @@ from ...vector import mgr as vectordb_mgr
|
||||
from .. import taskmgr
|
||||
from ...telemetry import telemetry as telemetry_module
|
||||
from ...survey import manager as survey_module
|
||||
from ...workspace import service as workspace_service_module
|
||||
from ...workspace import collaboration as workspace_collaboration_module
|
||||
from ...workspace import policy as workspace_policy_module
|
||||
from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ...api.http.authz import WorkspaceRequiredError
|
||||
|
||||
|
||||
@stage.stage_class('BuildAppStage')
|
||||
@@ -51,9 +56,6 @@ class BuildAppStage(stage.BootingStage):
|
||||
discover.discover_blueprint('templates/components.yaml')
|
||||
ap.discover = discover
|
||||
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
ap.user_service = user_service_inst
|
||||
|
||||
space_service_inst = space_service.SpaceService(ap)
|
||||
ap.space_service = space_service_inst
|
||||
|
||||
@@ -98,8 +100,6 @@ class BuildAppStage(stage.BootingStage):
|
||||
await ver_mgr.initialize()
|
||||
ap.ver_mgr = ver_mgr
|
||||
|
||||
ap.query_pool = pool.QueryPool()
|
||||
|
||||
log_cache = logcache.LogCache()
|
||||
ap.log_cache = log_cache
|
||||
|
||||
@@ -111,6 +111,43 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
# The open-source Core is intentionally single-Workspace. A mutable
|
||||
# config value such as ``system.edition`` is product metadata, not a
|
||||
# trust credential, and must never activate SaaS routing. The closed
|
||||
# Cloud bootstrap will install its verified policy only after checking
|
||||
# a signed InstanceManifest; until that bootstrap exists, fail closed.
|
||||
workspace_policy = workspace_policy_module.open_core_workspace_policy()
|
||||
workspace_service_inst = workspace_service_module.WorkspaceService(
|
||||
ap,
|
||||
policy=workspace_policy,
|
||||
)
|
||||
if not workspace_policy.multi_workspace_enabled:
|
||||
await workspace_service_inst.ensure_singleton_workspace()
|
||||
ap.workspace_service = workspace_service_inst
|
||||
|
||||
ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
|
||||
ap,
|
||||
workspace_service_inst,
|
||||
)
|
||||
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
ap.user_service = user_service_inst
|
||||
|
||||
async def resolve_singleton_execution_context() -> ExecutionContext:
|
||||
if workspace_policy.multi_workspace_enabled:
|
||||
raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
|
||||
binding = await workspace_service_inst.get_local_execution_binding()
|
||||
return ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
)
|
||||
|
||||
ap.query_pool = pool.QueryPool(
|
||||
singleton_context_resolver=resolve_singleton_execution_context,
|
||||
)
|
||||
|
||||
# Telemetry manager: attach to app so other components can call via self.ap.telemetry
|
||||
telemetry_inst = telemetry_module.TelemetryManager(ap)
|
||||
await telemetry_inst.initialize()
|
||||
|
||||
@@ -98,6 +98,15 @@ class TaskWrapper:
|
||||
scopes: list[core_entities.LifecycleControlScope]
|
||||
"""Task scope"""
|
||||
|
||||
instance_uuid: str | None
|
||||
"""Owning LangBot instance for a tenant user task."""
|
||||
|
||||
workspace_uuid: str | None
|
||||
"""Owning Workspace for a tenant user task."""
|
||||
|
||||
placement_generation: int | None
|
||||
"""Workspace execution fence captured when the task was created."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: app.Application,
|
||||
@@ -108,6 +117,9 @@ class TaskWrapper:
|
||||
label: str = '',
|
||||
context: TaskContext = None,
|
||||
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
):
|
||||
self.id = TaskWrapper._id_index
|
||||
TaskWrapper._id_index += 1
|
||||
@@ -120,6 +132,9 @@ class TaskWrapper:
|
||||
self.label = label if label != '' else name
|
||||
self.task.set_name(name)
|
||||
self.scopes = scopes
|
||||
self.instance_uuid = instance_uuid
|
||||
self.workspace_uuid = workspace_uuid
|
||||
self.placement_generation = placement_generation
|
||||
self.created_at = time.time()
|
||||
|
||||
def assume_exception(self):
|
||||
@@ -155,6 +170,8 @@ class TaskWrapper:
|
||||
'kind': self.kind,
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'workspace_uuid': self.workspace_uuid,
|
||||
'placement_generation': self.placement_generation,
|
||||
'scopes': [scope.value for scope in self.scopes],
|
||||
'created_at': self.created_at,
|
||||
'task_context': self.task_context.to_dict(),
|
||||
@@ -193,8 +210,23 @@ class AsyncTaskManager:
|
||||
label: str = '',
|
||||
context: TaskContext = None,
|
||||
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
) -> TaskWrapper:
|
||||
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
||||
wrapper = TaskWrapper(
|
||||
self.ap,
|
||||
coro,
|
||||
task_type,
|
||||
kind,
|
||||
name,
|
||||
label,
|
||||
context,
|
||||
scopes,
|
||||
instance_uuid,
|
||||
workspace_uuid,
|
||||
placement_generation,
|
||||
)
|
||||
self.tasks.append(wrapper)
|
||||
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
|
||||
self._prune_completed_tasks()
|
||||
@@ -208,8 +240,22 @@ class AsyncTaskManager:
|
||||
label: str = '',
|
||||
context: TaskContext = None,
|
||||
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
) -> TaskWrapper:
|
||||
return self.create_task(coro, 'user', kind, name, label, context, scopes)
|
||||
return self.create_task(
|
||||
coro,
|
||||
'user',
|
||||
kind,
|
||||
name,
|
||||
label,
|
||||
context,
|
||||
scopes,
|
||||
instance_uuid,
|
||||
workspace_uuid,
|
||||
placement_generation,
|
||||
)
|
||||
|
||||
async def wait_all(self):
|
||||
await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True)
|
||||
@@ -221,12 +267,20 @@ class AsyncTaskManager:
|
||||
self,
|
||||
type: str = None,
|
||||
kind: str = None,
|
||||
*,
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
'tasks': [
|
||||
t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
|
||||
if (type is None or t.task_type == type)
|
||||
and (kind is None or t.kind == kind)
|
||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||
],
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
@@ -240,9 +294,21 @@ class AsyncTaskManager:
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
|
||||
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
||||
def get_task_by_id(
|
||||
self,
|
||||
id: int,
|
||||
*,
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
) -> TaskWrapper | None:
|
||||
for t in self.tasks:
|
||||
if t.id == id:
|
||||
if (
|
||||
t.id == id
|
||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||
):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
import enum
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ApiKeyStatus(enum.StrEnum):
|
||||
ACTIVE = 'active'
|
||||
REVOKED = 'revoked'
|
||||
|
||||
|
||||
def _new_uuid() -> str:
|
||||
return str(uuid_lib.uuid4())
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Key for external service authentication"""
|
||||
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False, default=_new_uuid)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
created_by_account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True)
|
||||
key_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
|
||||
scopes = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=list, server_default='[]')
|
||||
status = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=ApiKeyStatus.ACTIVE.value,
|
||||
)
|
||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
@@ -19,3 +50,16 @@ class ApiKey(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_api_keys_uuid', 'uuid', unique=True),
|
||||
# Authentication begins with the presented secret, before a Workspace
|
||||
# can be trusted, so hashes remain globally unique.
|
||||
sqlalchemy.Index('uq_api_keys_key_hash', 'key_hash', unique=True),
|
||||
sqlalchemy.Index('ix_api_keys_workspace_name', 'workspace_uuid', 'name'),
|
||||
sqlalchemy.Index('ix_api_keys_workspace_status', 'workspace_uuid', 'status'),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"status IN ('active', 'revoked')",
|
||||
name='ck_api_keys_status',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,12 +9,31 @@ class BotAdmin(Base):
|
||||
__tablename__ = 'bot_admins'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
|
||||
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
|
||||
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint(
|
||||
'workspace_uuid',
|
||||
'bot_uuid',
|
||||
'launcher_type',
|
||||
'launcher_id',
|
||||
name='uq_bot_admin',
|
||||
),
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'bot_uuid'],
|
||||
['bots.workspace_uuid', 'bots.uuid'],
|
||||
name='fk_bot_admins_workspace_bot',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Bot(Base):
|
||||
@@ -23,6 +42,11 @@ class Bot(Base):
|
||||
__tablename__ = 'bots'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
@@ -38,3 +62,8 @@ class Bot(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_bots_workspace_uuid'),
|
||||
sqlalchemy.Index('ix_bots_workspace_name', 'workspace_uuid', 'name'),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,11 @@ class BinaryStorage(Base):
|
||||
|
||||
__tablename__ = 'binary_storages'
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
unique_key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
owner_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
@@ -20,3 +25,12 @@ class BinaryStorage(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index(
|
||||
'ix_binary_storages_workspace_owner',
|
||||
'workspace_uuid',
|
||||
'owner_type',
|
||||
'owner',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,11 @@ class MCPServer(Base):
|
||||
__tablename__ = 'mcp_servers'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http)
|
||||
@@ -22,3 +27,8 @@ class MCPServer(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'name', name='uq_mcp_servers_workspace_name'),
|
||||
sqlalchemy.Index('ix_mcp_servers_workspace_enable', 'workspace_uuid', 'enable'),
|
||||
)
|
||||
|
||||
@@ -19,3 +19,17 @@ class Metadata(Base):
|
||||
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
value = sqlalchemy.Column(sqlalchemy.String(255))
|
||||
|
||||
|
||||
class WorkspaceMetadata(Base):
|
||||
"""Metadata owned by one workspace rather than by the LangBot instance."""
|
||||
|
||||
__tablename__ = 'workspace_metadata'
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
value = sqlalchemy.Column(sqlalchemy.String(255))
|
||||
|
||||
@@ -9,6 +9,11 @@ class ModelProvider(Base):
|
||||
__tablename__ = 'model_providers'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
|
||||
@@ -21,6 +26,12 @@ class ModelProvider(Base):
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_model_providers_workspace_uuid'),
|
||||
sqlalchemy.Index('ix_model_providers_workspace_name', 'workspace_uuid', 'name'),
|
||||
sqlalchemy.Index('ix_model_providers_workspace_requester', 'workspace_uuid', 'requester'),
|
||||
)
|
||||
|
||||
|
||||
class LLMModel(Base):
|
||||
"""LLM model"""
|
||||
@@ -28,6 +39,11 @@ class LLMModel(Base):
|
||||
__tablename__ = 'llm_models'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||
@@ -42,6 +58,16 @@ class LLMModel(Base):
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_llm_models_workspace_provider',
|
||||
),
|
||||
sqlalchemy.Index('ix_llm_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
|
||||
sqlalchemy.Index('ix_llm_models_workspace_name', 'workspace_uuid', 'name'),
|
||||
)
|
||||
|
||||
|
||||
class EmbeddingModel(Base):
|
||||
"""Embedding model"""
|
||||
@@ -49,6 +75,11 @@ class EmbeddingModel(Base):
|
||||
__tablename__ = 'embedding_models'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
@@ -61,6 +92,16 @@ class EmbeddingModel(Base):
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_embedding_models_workspace_provider',
|
||||
),
|
||||
sqlalchemy.Index('ix_embedding_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
|
||||
sqlalchemy.Index('ix_embedding_models_workspace_name', 'workspace_uuid', 'name'),
|
||||
)
|
||||
|
||||
|
||||
class RerankModel(Base):
|
||||
"""Rerank model"""
|
||||
@@ -68,6 +109,11 @@ class RerankModel(Base):
|
||||
__tablename__ = 'rerank_models'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
@@ -79,3 +125,13 @@ class RerankModel(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_rerank_models_workspace_provider',
|
||||
),
|
||||
sqlalchemy.Index('ix_rerank_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
|
||||
sqlalchemy.Index('ix_rerank_models_workspace_name', 'workspace_uuid', 'name'),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,11 @@ class MonitoringMessage(Base):
|
||||
__tablename__ = 'monitoring_messages'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
@@ -25,6 +30,11 @@ class MonitoringMessage(Base):
|
||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringLLMCall(Base):
|
||||
"""LLM call records"""
|
||||
@@ -32,6 +42,11 @@ class MonitoringLLMCall(Base):
|
||||
__tablename__ = 'monitoring_llm_calls'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
@@ -48,6 +63,11 @@ class MonitoringLLMCall(Base):
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringToolCall(Base):
|
||||
"""Tool call records"""
|
||||
@@ -55,6 +75,11 @@ class MonitoringToolCall(Base):
|
||||
__tablename__ = 'monitoring_tool_calls'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
|
||||
@@ -70,12 +95,22 @@ class MonitoringToolCall(Base):
|
||||
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_tool_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_tool_calls_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringSession(Base):
|
||||
"""Session tracking records"""
|
||||
|
||||
__tablename__ = 'monitoring_sessions'
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
@@ -89,6 +124,11 @@ class MonitoringSession(Base):
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_sessions_workspace_activity', 'workspace_uuid', 'last_activity'),
|
||||
sqlalchemy.Index('ix_monitoring_sessions_workspace_active', 'workspace_uuid', 'is_active'),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringError(Base):
|
||||
"""Error log records"""
|
||||
@@ -96,6 +136,11 @@ class MonitoringError(Base):
|
||||
__tablename__ = 'monitoring_errors'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
@@ -107,6 +152,11 @@ class MonitoringError(Base):
|
||||
stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_errors_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_errors_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringEmbeddingCall(Base):
|
||||
"""Embedding call records"""
|
||||
@@ -114,6 +164,11 @@ class MonitoringEmbeddingCall(Base):
|
||||
__tablename__ = 'monitoring_embedding_calls'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||
@@ -129,6 +184,19 @@ class MonitoringEmbeddingCall(Base):
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_embedding_calls_workspace_timestamp',
|
||||
'workspace_uuid',
|
||||
'timestamp',
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_embedding_calls_workspace_kb',
|
||||
'workspace_uuid',
|
||||
'knowledge_base_id',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringFeedback(Base):
|
||||
"""User feedback records (like/dislike) from AI Bot conversations"""
|
||||
@@ -136,8 +204,13 @@ class MonitoringFeedback(Base):
|
||||
__tablename__ = 'monitoring_feedback'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
|
||||
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
|
||||
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
|
||||
@@ -151,3 +224,13 @@ class MonitoringFeedback(Base):
|
||||
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint(
|
||||
'workspace_uuid',
|
||||
'feedback_id',
|
||||
name='uq_monitoring_feedback_workspace_feedback_id',
|
||||
),
|
||||
sqlalchemy.Index('ix_monitoring_feedback_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_feedback_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,11 @@ class LegacyPipeline(Base):
|
||||
__tablename__ = 'legacy_pipelines'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️')
|
||||
@@ -36,6 +41,16 @@ class LegacyPipeline(Base):
|
||||
},
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint(
|
||||
'workspace_uuid',
|
||||
'uuid',
|
||||
name='uq_legacy_pipelines_workspace_uuid',
|
||||
),
|
||||
sqlalchemy.Index('ix_legacy_pipelines_workspace_name', 'workspace_uuid', 'name'),
|
||||
sqlalchemy.Index('ix_legacy_pipelines_workspace_default', 'workspace_uuid', 'is_default'),
|
||||
)
|
||||
|
||||
|
||||
class PipelineRunRecord(Base):
|
||||
"""Pipeline run record"""
|
||||
@@ -43,6 +58,11 @@ class PipelineRunRecord(Base):
|
||||
__tablename__ = 'pipeline_run_records'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
status = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
@@ -56,3 +76,22 @@ class PipelineRunRecord(Base):
|
||||
finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
result = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'pipeline_uuid'],
|
||||
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
|
||||
name='fk_pipeline_run_records_workspace_pipeline',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_pipeline_run_records_workspace_pipeline',
|
||||
'workspace_uuid',
|
||||
'pipeline_uuid',
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_pipeline_run_records_workspace_created',
|
||||
'workspace_uuid',
|
||||
'created_at',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,11 @@ class PluginSetting(Base):
|
||||
|
||||
__tablename__ = 'plugin_settings'
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
plugin_author = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
plugin_name = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
||||
@@ -22,3 +27,5 @@ class PluginSetting(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (sqlalchemy.Index('ix_plugin_settings_workspace_enabled', 'workspace_uuid', 'enabled'),)
|
||||
|
||||
@@ -5,6 +5,11 @@ from .base import Base
|
||||
class KnowledgeBase(Base):
|
||||
__tablename__ = 'knowledge_bases'
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String, index=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.Text)
|
||||
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚')
|
||||
@@ -13,6 +18,15 @@ class KnowledgeBase(Base):
|
||||
# New fields for plugin-based RAG
|
||||
knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
|
||||
collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
|
||||
# Server-managed compatibility marker. Pre-tenancy installations stored
|
||||
# vectors directly under ``collection_id``; new knowledge bases use a
|
||||
# tenant-derived opaque physical collection instead.
|
||||
legacy_vector_collection = sqlalchemy.Column(
|
||||
sqlalchemy.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sqlalchemy.false(),
|
||||
)
|
||||
creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
|
||||
retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
|
||||
|
||||
@@ -23,22 +37,72 @@ class KnowledgeBase(Base):
|
||||
CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'}
|
||||
"""Fields used when creating a new knowledge base."""
|
||||
|
||||
ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'}
|
||||
ALL_DB_FIELDS = CREATE_FIELDS | {
|
||||
'workspace_uuid',
|
||||
'legacy_vector_collection',
|
||||
'emoji',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
}
|
||||
"""All fields stored in database (for loading from DB row)."""
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_knowledge_bases_workspace_uuid'),
|
||||
sqlalchemy.Index('ix_knowledge_bases_workspace_name', 'workspace_uuid', 'name'),
|
||||
sqlalchemy.Index(
|
||||
'uq_knowledge_bases_workspace_collection',
|
||||
'workspace_uuid',
|
||||
'collection_id',
|
||||
unique=True,
|
||||
sqlite_where=sqlalchemy.text('collection_id IS NOT NULL'),
|
||||
postgresql_where=sqlalchemy.text('collection_id IS NOT NULL'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class File(Base):
|
||||
__tablename__ = 'knowledge_base_files'
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
kb_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
file_name = sqlalchemy.Column(sqlalchemy.String)
|
||||
extension = sqlalchemy.Column(sqlalchemy.String)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
|
||||
status = sqlalchemy.Column(sqlalchemy.String, default='pending') # pending, processing, completed, failed
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_knowledge_base_files_workspace_uuid'),
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'kb_id'],
|
||||
['knowledge_bases.workspace_uuid', 'knowledge_bases.uuid'],
|
||||
name='fk_knowledge_base_files_workspace_kb',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sqlalchemy.Index('ix_knowledge_base_files_workspace_kb', 'workspace_uuid', 'kb_id'),
|
||||
)
|
||||
|
||||
|
||||
class Chunk(Base):
|
||||
__tablename__ = 'knowledge_base_chunks'
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
text = sqlalchemy.Column(sqlalchemy.Text)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'file_id'],
|
||||
['knowledge_base_files.workspace_uuid', 'knowledge_base_files.uuid'],
|
||||
name='fk_knowledge_base_chunks_workspace_file',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sqlalchemy.Index('ix_knowledge_base_chunks_workspace_file', 'workspace_uuid', 'file_id'),
|
||||
)
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import enum
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AccountStatus(enum.StrEnum):
|
||||
ACTIVE = 'active'
|
||||
DISABLED = 'disabled'
|
||||
DELETED = 'deleted'
|
||||
|
||||
|
||||
class AccountSource(enum.StrEnum):
|
||||
LOCAL = 'local'
|
||||
CLOUD_PROJECTION = 'cloud_projection'
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
|
||||
uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
nullable=False,
|
||||
default=lambda: str(uuid_lib.uuid4()),
|
||||
)
|
||||
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
normalized_email = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
|
||||
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
|
||||
status = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=AccountStatus.ACTIVE.value,
|
||||
)
|
||||
source = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=AccountSource.LOCAL.value,
|
||||
)
|
||||
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
||||
|
||||
# Account type: 'local' (default) or 'space'
|
||||
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
|
||||
|
||||
@@ -27,3 +59,22 @@ class User(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_users_uuid', 'uuid', unique=True),
|
||||
sqlalchemy.Index('uq_users_normalized_email', 'normalized_email', unique=True),
|
||||
sqlalchemy.CheckConstraint(
|
||||
'normalized_email = trim(normalized_email) '
|
||||
'AND length(normalized_email) > 0 '
|
||||
'AND length(normalized_email) <= 320',
|
||||
name='ck_users_normalized_email',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"status IN ('active', 'disabled', 'deleted')",
|
||||
name='ck_users_status',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
name='ck_users_source',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,11 @@ class Webhook(Base):
|
||||
__tablename__ = 'webhooks'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
|
||||
@@ -20,3 +25,5 @@ class Webhook(Base):
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (sqlalchemy.Index('ix_webhooks_workspace_name', 'workspace_uuid', 'name'),)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class WorkspaceType(enum.StrEnum):
|
||||
PERSONAL = 'personal'
|
||||
TEAM = 'team'
|
||||
|
||||
|
||||
class WorkspaceStatus(enum.StrEnum):
|
||||
PROVISIONING = 'provisioning'
|
||||
ACTIVE = 'active'
|
||||
SUSPENDED = 'suspended'
|
||||
ARCHIVED = 'archived'
|
||||
DELETED = 'deleted'
|
||||
|
||||
|
||||
class WorkspaceSource(enum.StrEnum):
|
||||
LOCAL = 'local'
|
||||
CLOUD_PROJECTION = 'cloud_projection'
|
||||
|
||||
|
||||
class MembershipRole(enum.StrEnum):
|
||||
OWNER = 'owner'
|
||||
ADMIN = 'admin'
|
||||
DEVELOPER = 'developer'
|
||||
OPERATOR = 'operator'
|
||||
VIEWER = 'viewer'
|
||||
|
||||
|
||||
class MembershipStatus(enum.StrEnum):
|
||||
ACTIVE = 'active'
|
||||
DISABLED = 'disabled'
|
||||
REMOVED = 'removed'
|
||||
|
||||
|
||||
class InvitationStatus(enum.StrEnum):
|
||||
PENDING = 'pending'
|
||||
ACCEPTED = 'accepted'
|
||||
REVOKED = 'revoked'
|
||||
EXPIRED = 'expired'
|
||||
|
||||
|
||||
class WorkspaceExecutionStatus(enum.StrEnum):
|
||||
PROVISIONING = 'provisioning'
|
||||
ACTIVE = 'active'
|
||||
MIGRATING = 'migrating'
|
||||
DRAINING = 'draining'
|
||||
INACTIVE = 'inactive'
|
||||
|
||||
|
||||
class WorkspaceExecutionSource(enum.StrEnum):
|
||||
LOCAL = 'local'
|
||||
CLOUD = 'cloud'
|
||||
|
||||
|
||||
def _new_uuid() -> str:
|
||||
return str(uuid_lib.uuid4())
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
__tablename__ = 'workspaces'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
|
||||
instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
slug = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
type = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=WorkspaceType.TEAM.value,
|
||||
)
|
||||
status = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=WorkspaceStatus.ACTIVE.value,
|
||||
)
|
||||
created_by_account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
source = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=WorkspaceSource.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(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
|
||||
sqlalchemy.Index('ix_workspaces_instance_status', 'instance_uuid', 'status'),
|
||||
sqlalchemy.Index(
|
||||
'uq_workspaces_local_instance',
|
||||
'instance_uuid',
|
||||
unique=True,
|
||||
sqlite_where=sqlalchemy.text("source = 'local'"),
|
||||
postgresql_where=sqlalchemy.text("source = 'local'"),
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"type IN ('personal', 'team')",
|
||||
name='ck_workspaces_type',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
|
||||
name='ck_workspaces_status',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
name='ck_workspaces_source',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMembership(Base):
|
||||
__tablename__ = 'workspace_memberships'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
|
||||
status = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=MembershipStatus.ACTIVE.value,
|
||||
)
|
||||
invited_by_account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
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(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
|
||||
sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||
name='ck_workspace_memberships_role',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"status IN ('active', 'disabled', 'removed')",
|
||||
name='ck_workspace_memberships_status',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceInvitation(Base):
|
||||
__tablename__ = 'workspace_invitations'
|
||||
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
normalized_email = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
|
||||
role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
|
||||
token_hash = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
status = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=InvitationStatus.PENDING.value,
|
||||
)
|
||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
accepted_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
created_by_account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_workspace_invitations_token_hash', 'token_hash', unique=True),
|
||||
sqlalchemy.Index(
|
||||
'uq_workspace_invitations_pending_email',
|
||||
'workspace_uuid',
|
||||
'normalized_email',
|
||||
unique=True,
|
||||
sqlite_where=sqlalchemy.text("status = 'pending'"),
|
||||
postgresql_where=sqlalchemy.text("status = 'pending'"),
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"role IN ('admin', 'developer', 'operator', 'viewer')",
|
||||
name='ck_workspace_invitations_role',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'revoked', 'expired')",
|
||||
name='ck_workspace_invitations_status',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceExecutionState(Base):
|
||||
__tablename__ = 'workspace_execution_states'
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
active_generation = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='1')
|
||||
state = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=WorkspaceExecutionStatus.ACTIVE.value,
|
||||
)
|
||||
write_fenced = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false())
|
||||
source = sqlalchemy.Column(
|
||||
sqlalchemy.String(32),
|
||||
nullable=False,
|
||||
server_default=WorkspaceExecutionSource.LOCAL.value,
|
||||
)
|
||||
desired_state_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_workspace_execution_states_instance_state', 'instance_uuid', 'state'),
|
||||
sqlalchemy.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
|
||||
name='ck_workspace_execution_state',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
"source IN ('local', 'cloud')",
|
||||
name='ck_workspace_execution_source',
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,542 @@
|
||||
"""add the workspace tenancy persistence kernel
|
||||
|
||||
Revision ID: 0009_workspace_tenancy
|
||||
Revises: 0008_mcp_resource_prefs
|
||||
Create Date: 2026-07-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0009_workspace_tenancy'
|
||||
down_revision = '0008_mcp_resource_prefs'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_names(conn: sa.Connection) -> set[str]:
|
||||
return set(sa.inspect(conn).get_table_names())
|
||||
|
||||
|
||||
def _column_map(conn: sa.Connection, table_name: str) -> dict[str, dict]:
|
||||
return {column['name']: column for column in sa.inspect(conn).get_columns(table_name)}
|
||||
|
||||
|
||||
def _constraint_names(conn: sa.Connection, table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(conn)
|
||||
names = {
|
||||
constraint['name']
|
||||
for constraint in inspector.get_check_constraints(table_name)
|
||||
if constraint.get('name') is not None
|
||||
}
|
||||
names.update(
|
||||
constraint['name']
|
||||
for constraint in inspector.get_unique_constraints(table_name)
|
||||
if constraint.get('name') is not None
|
||||
)
|
||||
return names
|
||||
|
||||
|
||||
def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
|
||||
return {index['name'] for index in sa.inspect(conn).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _upgrade_users(conn: sa.Connection) -> None:
|
||||
if 'users' not in _table_names(conn):
|
||||
return
|
||||
|
||||
columns = _column_map(conn, 'users')
|
||||
if 'uuid' not in columns:
|
||||
op.add_column('users', sa.Column('uuid', sa.String(36), nullable=True))
|
||||
if 'status' not in columns:
|
||||
op.add_column('users', sa.Column('status', sa.String(32), nullable=True, server_default='active'))
|
||||
if 'source' not in columns:
|
||||
op.add_column('users', sa.Column('source', sa.String(32), nullable=True, server_default='local'))
|
||||
if 'projection_revision' not in columns:
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('projection_revision', sa.BigInteger(), nullable=True, server_default='0'),
|
||||
)
|
||||
|
||||
users = sa.table(
|
||||
'users',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('uuid', sa.String(36)),
|
||||
sa.column('status', sa.String(32)),
|
||||
sa.column('source', sa.String(32)),
|
||||
sa.column('projection_revision', sa.BigInteger()),
|
||||
)
|
||||
|
||||
seen_uuids: set[str] = set()
|
||||
for user_id, account_uuid in conn.execute(sa.select(users.c.id, users.c.uuid).order_by(users.c.id)).all():
|
||||
normalized_uuid = account_uuid.strip() if isinstance(account_uuid, str) else ''
|
||||
try:
|
||||
normalized_uuid = str(uuid.UUID(normalized_uuid))
|
||||
except (ValueError, AttributeError):
|
||||
normalized_uuid = ''
|
||||
if not normalized_uuid or normalized_uuid in seen_uuids:
|
||||
normalized_uuid = str(uuid.uuid4())
|
||||
if normalized_uuid != account_uuid:
|
||||
conn.execute(users.update().where(users.c.id == user_id).values(uuid=normalized_uuid))
|
||||
seen_uuids.add(normalized_uuid)
|
||||
|
||||
conn.execute(users.update().where(users.c.status.is_(None)).values(status='active'))
|
||||
conn.execute(users.update().where(users.c.source.is_(None)).values(source='local'))
|
||||
conn.execute(users.update().where(users.c.projection_revision.is_(None)).values(projection_revision=0))
|
||||
|
||||
columns = _column_map(conn, 'users')
|
||||
constraint_names = _constraint_names(conn, 'users')
|
||||
needs_batch_alter = any(
|
||||
columns[column_name]['nullable'] for column_name in ('uuid', 'status', 'source', 'projection_revision')
|
||||
) or not {'ck_users_status', 'ck_users_source'}.issubset(constraint_names)
|
||||
|
||||
if needs_batch_alter:
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
if columns['uuid']['nullable']:
|
||||
batch_op.alter_column('uuid', existing_type=sa.String(36), nullable=False)
|
||||
if columns['status']['nullable']:
|
||||
batch_op.alter_column(
|
||||
'status',
|
||||
existing_type=sa.String(32),
|
||||
nullable=False,
|
||||
server_default='active',
|
||||
)
|
||||
if columns['source']['nullable']:
|
||||
batch_op.alter_column(
|
||||
'source',
|
||||
existing_type=sa.String(32),
|
||||
nullable=False,
|
||||
server_default='local',
|
||||
)
|
||||
if columns['projection_revision']['nullable']:
|
||||
batch_op.alter_column(
|
||||
'projection_revision',
|
||||
existing_type=sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default='0',
|
||||
)
|
||||
if 'ck_users_status' not in constraint_names:
|
||||
batch_op.create_check_constraint(
|
||||
'ck_users_status',
|
||||
"status IN ('active', 'disabled', 'deleted')",
|
||||
)
|
||||
if 'ck_users_source' not in constraint_names:
|
||||
batch_op.create_check_constraint(
|
||||
'ck_users_source',
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
)
|
||||
|
||||
if 'uq_users_uuid' not in _index_names(conn, 'users'):
|
||||
op.create_index('uq_users_uuid', 'users', ['uuid'], unique=True)
|
||||
|
||||
|
||||
def _create_workspace_tables(conn: sa.Connection) -> None:
|
||||
tables = _table_names(conn)
|
||||
if 'users' not in tables:
|
||||
# LangBot's supported startup path creates the baseline schema before
|
||||
# Alembic runs. Keep direct Alembic probes on an empty database safe.
|
||||
return
|
||||
|
||||
if 'workspaces' not in tables:
|
||||
op.create_table(
|
||||
'workspaces',
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('slug', sa.String(255), nullable=False),
|
||||
sa.Column('type', sa.String(32), nullable=False, server_default='team'),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='active'),
|
||||
sa.Column('created_by_account_uuid', sa.String(36), nullable=True),
|
||||
sa.Column('source', sa.String(32), nullable=False, server_default='local'),
|
||||
sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['created_by_account_uuid'],
|
||||
['users.uuid'],
|
||||
name='fk_workspaces_created_by_account',
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
sa.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
|
||||
sa.CheckConstraint("type IN ('personal', 'team')", name='ck_workspaces_type'),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
|
||||
name='ck_workspaces_status',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"source IN ('local', 'cloud_projection')",
|
||||
name='ck_workspaces_source',
|
||||
),
|
||||
)
|
||||
workspace_indexes = _index_names(conn, 'workspaces')
|
||||
if 'ix_workspaces_instance_status' not in workspace_indexes:
|
||||
op.create_index(
|
||||
'ix_workspaces_instance_status',
|
||||
'workspaces',
|
||||
['instance_uuid', 'status'],
|
||||
)
|
||||
if 'uq_workspaces_local_instance' not in workspace_indexes:
|
||||
op.create_index(
|
||||
'uq_workspaces_local_instance',
|
||||
'workspaces',
|
||||
['instance_uuid'],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("source = 'local'"),
|
||||
postgresql_where=sa.text("source = 'local'"),
|
||||
)
|
||||
|
||||
tables = _table_names(conn)
|
||||
if 'workspace_memberships' not in tables:
|
||||
op.create_table(
|
||||
'workspace_memberships',
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('account_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('role', sa.String(32), nullable=False),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='active'),
|
||||
sa.Column('invited_by_account_uuid', sa.String(36), nullable=True),
|
||||
sa.Column('joined_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid'],
|
||||
['workspaces.uuid'],
|
||||
name='fk_workspace_memberships_workspace',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['account_uuid'],
|
||||
['users.uuid'],
|
||||
name='fk_workspace_memberships_account',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['invited_by_account_uuid'],
|
||||
['users.uuid'],
|
||||
name='fk_workspace_memberships_invited_by_account',
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
'workspace_uuid',
|
||||
'account_uuid',
|
||||
name='uq_workspace_membership_account',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
|
||||
name='ck_workspace_memberships_role',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'disabled', 'removed')",
|
||||
name='ck_workspace_memberships_status',
|
||||
),
|
||||
)
|
||||
membership_indexes = _index_names(conn, 'workspace_memberships')
|
||||
if 'ix_workspace_memberships_account_status' not in membership_indexes:
|
||||
op.create_index(
|
||||
'ix_workspace_memberships_account_status',
|
||||
'workspace_memberships',
|
||||
['account_uuid', 'status'],
|
||||
)
|
||||
|
||||
tables = _table_names(conn)
|
||||
if 'workspace_invitations' not in tables:
|
||||
op.create_table(
|
||||
'workspace_invitations',
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('normalized_email', sa.String(320), nullable=False),
|
||||
sa.Column('role', sa.String(32), nullable=False),
|
||||
sa.Column('token_hash', sa.String(255), nullable=False),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('accepted_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_by_account_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid'],
|
||||
['workspaces.uuid'],
|
||||
name='fk_workspace_invitations_workspace',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['created_by_account_uuid'],
|
||||
['users.uuid'],
|
||||
name='fk_workspace_invitations_created_by_account',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"role IN ('admin', 'developer', 'operator', 'viewer')",
|
||||
name='ck_workspace_invitations_role',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'revoked', 'expired')",
|
||||
name='ck_workspace_invitations_status',
|
||||
),
|
||||
)
|
||||
invitation_indexes = _index_names(conn, 'workspace_invitations')
|
||||
if 'uq_workspace_invitations_token_hash' not in invitation_indexes:
|
||||
op.create_index(
|
||||
'uq_workspace_invitations_token_hash',
|
||||
'workspace_invitations',
|
||||
['token_hash'],
|
||||
unique=True,
|
||||
)
|
||||
if 'uq_workspace_invitations_pending_email' not in invitation_indexes:
|
||||
op.create_index(
|
||||
'uq_workspace_invitations_pending_email',
|
||||
'workspace_invitations',
|
||||
['workspace_uuid', 'normalized_email'],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("status = 'pending'"),
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
|
||||
tables = _table_names(conn)
|
||||
if 'workspace_execution_states' not in tables:
|
||||
op.create_table(
|
||||
'workspace_execution_states',
|
||||
sa.Column('workspace_uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('active_generation', sa.BigInteger(), nullable=False, server_default='1'),
|
||||
sa.Column('state', sa.String(32), nullable=False, server_default='active'),
|
||||
sa.Column('write_fenced', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column('source', sa.String(32), nullable=False, server_default='local'),
|
||||
sa.Column('desired_state_revision', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid'],
|
||||
['workspaces.uuid'],
|
||||
name='fk_workspace_execution_states_workspace',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
|
||||
sa.CheckConstraint(
|
||||
"state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
|
||||
name='ck_workspace_execution_state',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"source IN ('local', 'cloud')",
|
||||
name='ck_workspace_execution_source',
|
||||
),
|
||||
)
|
||||
execution_indexes = _index_names(conn, 'workspace_execution_states')
|
||||
if 'ix_workspace_execution_states_instance_state' not in execution_indexes:
|
||||
op.create_index(
|
||||
'ix_workspace_execution_states_instance_state',
|
||||
'workspace_execution_states',
|
||||
['instance_uuid', 'state'],
|
||||
)
|
||||
|
||||
|
||||
def _load_instance_uuid(conn: sa.Connection) -> str | None:
|
||||
if 'metadata' not in _table_names(conn):
|
||||
return None
|
||||
|
||||
metadata = sa.table(
|
||||
'metadata',
|
||||
sa.column('key', sa.String(255)),
|
||||
sa.column('value', sa.String(255)),
|
||||
)
|
||||
value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid')).scalar_one_or_none()
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _bootstrap_default_workspace(conn: sa.Connection) -> None:
|
||||
required_tables = {'users', 'workspaces', 'workspace_memberships', 'workspace_execution_states'}
|
||||
if not required_tables.issubset(_table_names(conn)):
|
||||
return
|
||||
|
||||
instance_uuid = _load_instance_uuid(conn)
|
||||
users_exist = 'users' in _table_names(conn) and bool(conn.execute(sa.text('SELECT 1 FROM users LIMIT 1')).first())
|
||||
if instance_uuid is None:
|
||||
if users_exist:
|
||||
raise RuntimeError("Cannot bootstrap the default workspace without metadata['instance_uuid']")
|
||||
return
|
||||
|
||||
workspaces = sa.table(
|
||||
'workspaces',
|
||||
sa.column('uuid', sa.String(36)),
|
||||
sa.column('instance_uuid', sa.String(255)),
|
||||
sa.column('name', sa.String(255)),
|
||||
sa.column('slug', sa.String(255)),
|
||||
sa.column('type', sa.String(32)),
|
||||
sa.column('status', sa.String(32)),
|
||||
sa.column('created_by_account_uuid', sa.String(36)),
|
||||
sa.column('source', sa.String(32)),
|
||||
sa.column('projection_revision', sa.BigInteger()),
|
||||
)
|
||||
local_rows = conn.execute(
|
||||
sa.select(workspaces.c.uuid).where(
|
||||
workspaces.c.instance_uuid == instance_uuid,
|
||||
workspaces.c.source == 'local',
|
||||
)
|
||||
).all()
|
||||
if len(local_rows) > 1:
|
||||
raise RuntimeError(f'Multiple local workspaces already exist for instance {instance_uuid!r}')
|
||||
|
||||
users = sa.table(
|
||||
'users',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('uuid', sa.String(36)),
|
||||
)
|
||||
owner_account_uuid = None
|
||||
if 'users' in _table_names(conn):
|
||||
owner_account_uuid = conn.execute(sa.select(users.c.uuid).order_by(users.c.id).limit(1)).scalar_one_or_none()
|
||||
|
||||
if local_rows:
|
||||
workspace_uuid = local_rows[0][0]
|
||||
if owner_account_uuid is not None:
|
||||
conn.execute(
|
||||
workspaces.update()
|
||||
.where(workspaces.c.uuid == workspace_uuid)
|
||||
.where(workspaces.c.created_by_account_uuid.is_(None))
|
||||
.values(created_by_account_uuid=owner_account_uuid)
|
||||
)
|
||||
else:
|
||||
workspace_uuid = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
workspaces.insert().values(
|
||||
uuid=workspace_uuid,
|
||||
instance_uuid=instance_uuid,
|
||||
name='Default Workspace',
|
||||
slug='default',
|
||||
type='team',
|
||||
status='active',
|
||||
created_by_account_uuid=owner_account_uuid,
|
||||
source='local',
|
||||
projection_revision=0,
|
||||
)
|
||||
)
|
||||
|
||||
execution_states = sa.table(
|
||||
'workspace_execution_states',
|
||||
sa.column('workspace_uuid', sa.String(36)),
|
||||
sa.column('instance_uuid', sa.String(255)),
|
||||
sa.column('active_generation', sa.BigInteger()),
|
||||
sa.column('state', sa.String(32)),
|
||||
sa.column('write_fenced', sa.Boolean()),
|
||||
sa.column('source', sa.String(32)),
|
||||
sa.column('desired_state_revision', sa.BigInteger()),
|
||||
)
|
||||
execution_state = conn.execute(
|
||||
sa.select(
|
||||
execution_states.c.instance_uuid,
|
||||
execution_states.c.active_generation,
|
||||
execution_states.c.state,
|
||||
execution_states.c.write_fenced,
|
||||
execution_states.c.source,
|
||||
).where(execution_states.c.workspace_uuid == workspace_uuid)
|
||||
).first()
|
||||
if execution_state is None:
|
||||
conn.execute(
|
||||
execution_states.insert().values(
|
||||
workspace_uuid=workspace_uuid,
|
||||
instance_uuid=instance_uuid,
|
||||
active_generation=1,
|
||||
state='active',
|
||||
write_fenced=False,
|
||||
source='local',
|
||||
desired_state_revision=0,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
execution_state.instance_uuid != instance_uuid
|
||||
or execution_state.active_generation != 1
|
||||
or execution_state.state != 'active'
|
||||
or execution_state.write_fenced
|
||||
or execution_state.source != 'local'
|
||||
):
|
||||
raise RuntimeError(f'Default workspace {workspace_uuid!r} has an invalid local execution state')
|
||||
|
||||
if owner_account_uuid is None:
|
||||
return
|
||||
|
||||
memberships = sa.table(
|
||||
'workspace_memberships',
|
||||
sa.column('uuid', sa.String(36)),
|
||||
sa.column('workspace_uuid', sa.String(36)),
|
||||
sa.column('account_uuid', sa.String(36)),
|
||||
sa.column('role', sa.String(32)),
|
||||
sa.column('status', sa.String(32)),
|
||||
sa.column('joined_at', sa.DateTime()),
|
||||
sa.column('projection_revision', sa.BigInteger()),
|
||||
)
|
||||
membership = conn.execute(
|
||||
sa.select(memberships.c.uuid, memberships.c.joined_at).where(
|
||||
memberships.c.workspace_uuid == workspace_uuid,
|
||||
memberships.c.account_uuid == owner_account_uuid,
|
||||
)
|
||||
).first()
|
||||
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
if membership is None:
|
||||
conn.execute(
|
||||
memberships.insert().values(
|
||||
uuid=str(uuid.uuid4()),
|
||||
workspace_uuid=workspace_uuid,
|
||||
account_uuid=owner_account_uuid,
|
||||
role='owner',
|
||||
status='active',
|
||||
joined_at=now,
|
||||
projection_revision=0,
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
memberships.update()
|
||||
.where(memberships.c.uuid == membership.uuid)
|
||||
.values(
|
||||
role='owner',
|
||||
status='active',
|
||||
joined_at=membership.joined_at or now,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_upgrade_users(conn)
|
||||
_create_workspace_tables(conn)
|
||||
_bootstrap_default_workspace(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
tables = _table_names(conn)
|
||||
for table_name in (
|
||||
'workspace_execution_states',
|
||||
'workspace_invitations',
|
||||
'workspace_memberships',
|
||||
'workspaces',
|
||||
):
|
||||
if table_name in tables:
|
||||
op.drop_table(table_name)
|
||||
|
||||
if 'users' not in _table_names(conn):
|
||||
return
|
||||
|
||||
indexes = _index_names(conn, 'users')
|
||||
if 'uq_users_uuid' in indexes:
|
||||
op.drop_index('uq_users_uuid', table_name='users')
|
||||
|
||||
columns = _column_map(conn, 'users')
|
||||
constraint_names = _constraint_names(conn, 'users')
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
# SQLite batch recreation otherwise preserves the named checks while
|
||||
# dropping their referenced columns, producing ``no such column`` only
|
||||
# after the Workspace directory tables have already been removed.
|
||||
for constraint_name in ('ck_users_source', 'ck_users_status'):
|
||||
if constraint_name in constraint_names:
|
||||
batch_op.drop_constraint(constraint_name, type_='check')
|
||||
for column_name in ('projection_revision', 'source', 'status', 'uuid'):
|
||||
if column_name in columns:
|
||||
batch_op.drop_column(column_name)
|
||||
@@ -0,0 +1,884 @@
|
||||
"""scope every tenant-owned resource to a workspace
|
||||
|
||||
Revision ID: 0010_scope_resources
|
||||
Revises: 0009_workspace_tenancy
|
||||
Create Date: 2026-07-19
|
||||
|
||||
This migration is intentionally expand/backfill/contract. Existing rows are
|
||||
bound to the single local Workspace created by revision 0009 before any
|
||||
non-null or scoped-key constraint is installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0010_scope_resources'
|
||||
down_revision = '0009_workspace_tenancy'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_TENANT_TABLES = (
|
||||
'api_keys',
|
||||
'bots',
|
||||
'bot_admins',
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
'legacy_pipelines',
|
||||
'pipeline_run_records',
|
||||
'plugin_settings',
|
||||
'knowledge_bases',
|
||||
'knowledge_base_files',
|
||||
'knowledge_base_chunks',
|
||||
'webhooks',
|
||||
'monitoring_messages',
|
||||
'monitoring_llm_calls',
|
||||
'monitoring_tool_calls',
|
||||
'monitoring_sessions',
|
||||
'monitoring_errors',
|
||||
'monitoring_embedding_calls',
|
||||
'monitoring_feedback',
|
||||
)
|
||||
|
||||
_COMPOSITE_PRIMARY_KEYS = {
|
||||
'binary_storages': ('workspace_uuid', 'unique_key'),
|
||||
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
|
||||
'monitoring_sessions': ('workspace_uuid', 'session_id'),
|
||||
}
|
||||
|
||||
_COMPOSITE_FOREIGN_KEYS = {
|
||||
'bot_admins': (
|
||||
(
|
||||
'fk_bot_admins_workspace_bot',
|
||||
('workspace_uuid', 'bot_uuid'),
|
||||
'bots',
|
||||
('workspace_uuid', 'uuid'),
|
||||
'CASCADE',
|
||||
),
|
||||
),
|
||||
'llm_models': (
|
||||
(
|
||||
'fk_llm_models_workspace_provider',
|
||||
('workspace_uuid', 'provider_uuid'),
|
||||
'model_providers',
|
||||
('workspace_uuid', 'uuid'),
|
||||
None,
|
||||
),
|
||||
),
|
||||
'embedding_models': (
|
||||
(
|
||||
'fk_embedding_models_workspace_provider',
|
||||
('workspace_uuid', 'provider_uuid'),
|
||||
'model_providers',
|
||||
('workspace_uuid', 'uuid'),
|
||||
None,
|
||||
),
|
||||
),
|
||||
'rerank_models': (
|
||||
(
|
||||
'fk_rerank_models_workspace_provider',
|
||||
('workspace_uuid', 'provider_uuid'),
|
||||
'model_providers',
|
||||
('workspace_uuid', 'uuid'),
|
||||
None,
|
||||
),
|
||||
),
|
||||
'pipeline_run_records': (
|
||||
(
|
||||
'fk_pipeline_run_records_workspace_pipeline',
|
||||
('workspace_uuid', 'pipeline_uuid'),
|
||||
'legacy_pipelines',
|
||||
('workspace_uuid', 'uuid'),
|
||||
'CASCADE',
|
||||
),
|
||||
),
|
||||
'knowledge_base_files': (
|
||||
(
|
||||
'fk_knowledge_base_files_workspace_kb',
|
||||
('workspace_uuid', 'kb_id'),
|
||||
'knowledge_bases',
|
||||
('workspace_uuid', 'uuid'),
|
||||
'CASCADE',
|
||||
),
|
||||
),
|
||||
'knowledge_base_chunks': (
|
||||
(
|
||||
'fk_knowledge_base_chunks_workspace_file',
|
||||
('workspace_uuid', 'file_id'),
|
||||
'knowledge_base_files',
|
||||
('workspace_uuid', 'uuid'),
|
||||
'CASCADE',
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
_SCOPED_INDEXES: dict[str, tuple[tuple[str, tuple[str, ...], bool, sa.TextClause | None], ...]] = {
|
||||
'api_keys': (
|
||||
('uq_api_keys_uuid', ('uuid',), True, None),
|
||||
('uq_api_keys_key_hash', ('key_hash',), True, None),
|
||||
('ix_api_keys_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
('ix_api_keys_workspace_status', ('workspace_uuid', 'status'), False, None),
|
||||
),
|
||||
'bots': (
|
||||
('uq_bots_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
|
||||
('ix_bots_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
('ix_bots_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
|
||||
),
|
||||
'bot_admins': (
|
||||
(
|
||||
'uq_bot_admin',
|
||||
('workspace_uuid', 'bot_uuid', 'launcher_type', 'launcher_id'),
|
||||
True,
|
||||
None,
|
||||
),
|
||||
('ix_bot_admins_workspace_bot', ('workspace_uuid', 'bot_uuid'), False, None),
|
||||
),
|
||||
'binary_storages': (
|
||||
(
|
||||
'ix_binary_storages_workspace_owner',
|
||||
('workspace_uuid', 'owner_type', 'owner'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
),
|
||||
'mcp_servers': (
|
||||
('uq_mcp_servers_workspace_name', ('workspace_uuid', 'name'), True, None),
|
||||
('ix_mcp_servers_workspace_enable', ('workspace_uuid', 'enable'), False, None),
|
||||
('ix_mcp_servers_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
|
||||
),
|
||||
'model_providers': (
|
||||
('uq_model_providers_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
|
||||
('ix_model_providers_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
('ix_model_providers_workspace_requester', ('workspace_uuid', 'requester'), False, None),
|
||||
),
|
||||
'llm_models': (
|
||||
('ix_llm_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
|
||||
('ix_llm_models_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
),
|
||||
'embedding_models': (
|
||||
('ix_embedding_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
|
||||
('ix_embedding_models_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
),
|
||||
'rerank_models': (
|
||||
('ix_rerank_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
|
||||
('ix_rerank_models_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
),
|
||||
'legacy_pipelines': (
|
||||
('uq_legacy_pipelines_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
|
||||
('ix_legacy_pipelines_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
('ix_legacy_pipelines_workspace_default', ('workspace_uuid', 'is_default'), False, None),
|
||||
('ix_legacy_pipelines_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
|
||||
),
|
||||
'pipeline_run_records': (
|
||||
(
|
||||
'ix_pipeline_run_records_workspace_pipeline',
|
||||
('workspace_uuid', 'pipeline_uuid'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
(
|
||||
'ix_pipeline_run_records_workspace_created',
|
||||
('workspace_uuid', 'created_at'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
),
|
||||
'plugin_settings': (('ix_plugin_settings_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),),
|
||||
'knowledge_bases': (
|
||||
('uq_knowledge_bases_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
|
||||
('ix_knowledge_bases_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
(
|
||||
'uq_knowledge_bases_workspace_collection',
|
||||
('workspace_uuid', 'collection_id'),
|
||||
True,
|
||||
sa.text('collection_id IS NOT NULL'),
|
||||
),
|
||||
),
|
||||
'knowledge_base_files': (
|
||||
('uq_knowledge_base_files_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
|
||||
('ix_knowledge_base_files_workspace_kb', ('workspace_uuid', 'kb_id'), False, None),
|
||||
),
|
||||
'knowledge_base_chunks': (('ix_knowledge_base_chunks_workspace_file', ('workspace_uuid', 'file_id'), False, None),),
|
||||
'webhooks': (
|
||||
('ix_webhooks_workspace_name', ('workspace_uuid', 'name'), False, None),
|
||||
('ix_webhooks_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),
|
||||
('ix_webhooks_workspace_created', ('workspace_uuid', 'created_at'), False, None),
|
||||
),
|
||||
'monitoring_messages': (
|
||||
('ix_monitoring_messages_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
|
||||
('ix_monitoring_messages_workspace_bot', ('workspace_uuid', 'bot_id', 'timestamp'), False, None),
|
||||
(
|
||||
'ix_monitoring_messages_workspace_pipeline',
|
||||
('workspace_uuid', 'pipeline_id', 'timestamp'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
('ix_monitoring_messages_workspace_session', ('workspace_uuid', 'session_id'), False, None),
|
||||
),
|
||||
'monitoring_llm_calls': (
|
||||
('ix_monitoring_llm_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
|
||||
('ix_monitoring_llm_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
|
||||
('ix_monitoring_llm_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
|
||||
),
|
||||
'monitoring_tool_calls': (
|
||||
('ix_monitoring_tool_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
|
||||
('ix_monitoring_tool_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
|
||||
('ix_monitoring_tool_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
|
||||
),
|
||||
'monitoring_sessions': (
|
||||
('ix_monitoring_sessions_workspace_activity', ('workspace_uuid', 'last_activity'), False, None),
|
||||
('ix_monitoring_sessions_workspace_active', ('workspace_uuid', 'is_active'), False, None),
|
||||
('ix_monitoring_sessions_workspace_bot', ('workspace_uuid', 'bot_id', 'last_activity'), False, None),
|
||||
),
|
||||
'monitoring_errors': (
|
||||
('ix_monitoring_errors_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
|
||||
('ix_monitoring_errors_workspace_session', ('workspace_uuid', 'session_id'), False, None),
|
||||
('ix_monitoring_errors_workspace_message', ('workspace_uuid', 'message_id'), False, None),
|
||||
),
|
||||
'monitoring_embedding_calls': (
|
||||
(
|
||||
'ix_monitoring_embedding_calls_workspace_timestamp',
|
||||
('workspace_uuid', 'timestamp'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
(
|
||||
'ix_monitoring_embedding_calls_workspace_kb',
|
||||
('workspace_uuid', 'knowledge_base_id'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
(
|
||||
'ix_monitoring_embedding_calls_workspace_session',
|
||||
('workspace_uuid', 'session_id'),
|
||||
False,
|
||||
None,
|
||||
),
|
||||
),
|
||||
'monitoring_feedback': (
|
||||
(
|
||||
'uq_monitoring_feedback_workspace_feedback_id',
|
||||
('workspace_uuid', 'feedback_id'),
|
||||
True,
|
||||
None,
|
||||
),
|
||||
('ix_monitoring_feedback_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
|
||||
('ix_monitoring_feedback_workspace_session', ('workspace_uuid', 'session_id'), False, None),
|
||||
('ix_monitoring_feedback_workspace_message', ('workspace_uuid', 'message_id'), False, None),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _inspector(conn: sa.Connection) -> sa.Inspector:
|
||||
return sa.inspect(conn)
|
||||
|
||||
|
||||
def _table_names(conn: sa.Connection) -> set[str]:
|
||||
return set(_inspector(conn).get_table_names())
|
||||
|
||||
|
||||
def _columns(conn: sa.Connection, table_name: str) -> dict[str, dict]:
|
||||
return {column['name']: column for column in _inspector(conn).get_columns(table_name)}
|
||||
|
||||
|
||||
def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
|
||||
return {index['name'] for index in _inspector(conn).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _unique_column_sets(conn: sa.Connection, table_name: str) -> set[tuple[str, ...]]:
|
||||
inspector = _inspector(conn)
|
||||
result = {
|
||||
tuple(constraint.get('column_names') or ()) for constraint in inspector.get_unique_constraints(table_name)
|
||||
}
|
||||
result.update(
|
||||
tuple(index.get('column_names') or ()) for index in inspector.get_indexes(table_name) if index.get('unique')
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _foreign_key_exists(
|
||||
conn: sa.Connection,
|
||||
table_name: str,
|
||||
local_columns: tuple[str, ...],
|
||||
referred_table: str,
|
||||
referred_columns: tuple[str, ...],
|
||||
) -> bool:
|
||||
return any(
|
||||
tuple(foreign_key.get('constrained_columns') or ()) == local_columns
|
||||
and foreign_key.get('referred_table') == referred_table
|
||||
and tuple(foreign_key.get('referred_columns') or ()) == referred_columns
|
||||
for foreign_key in _inspector(conn).get_foreign_keys(table_name)
|
||||
)
|
||||
|
||||
|
||||
def _metadata_value(conn: sa.Connection, key: str) -> str | None:
|
||||
if 'metadata' not in _table_names(conn):
|
||||
return None
|
||||
metadata = sa.table(
|
||||
'metadata',
|
||||
sa.column('key', sa.String(255)),
|
||||
sa.column('value', sa.String(255)),
|
||||
)
|
||||
value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == key)).scalar_one_or_none()
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _default_workspace_uuid(conn: sa.Connection) -> str | None:
|
||||
if 'workspaces' not in _table_names(conn):
|
||||
return None
|
||||
workspaces = sa.table(
|
||||
'workspaces',
|
||||
sa.column('uuid', sa.String(36)),
|
||||
sa.column('instance_uuid', sa.String(255)),
|
||||
sa.column('source', sa.String(32)),
|
||||
)
|
||||
instance_uuid = _metadata_value(conn, 'instance_uuid')
|
||||
query = sa.select(workspaces.c.uuid).where(workspaces.c.source == 'local')
|
||||
if instance_uuid is not None:
|
||||
query = query.where(workspaces.c.instance_uuid == instance_uuid)
|
||||
rows = conn.execute(query).all()
|
||||
if len(rows) > 1:
|
||||
raise RuntimeError('Cannot backfill tenant resources: multiple local Workspaces exist')
|
||||
return rows[0][0] if rows else None
|
||||
|
||||
|
||||
def _upgrade_normalized_email(conn: sa.Connection) -> None:
|
||||
if 'users' not in _table_names(conn):
|
||||
return
|
||||
columns = _columns(conn, 'users')
|
||||
if 'normalized_email' not in columns:
|
||||
op.add_column('users', sa.Column('normalized_email', sa.String(320), nullable=True))
|
||||
|
||||
users = sa.table(
|
||||
'users',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('user', sa.String(255)),
|
||||
sa.column('normalized_email', sa.String(320)),
|
||||
)
|
||||
# Use the exact same normalization algorithm as the runtime. Database
|
||||
# ``lower()`` is ASCII-only on SQLite and is not equivalent to Python
|
||||
# ``casefold()`` (for example, Straße -> strasse). Recompute every row so
|
||||
# an interrupted expand/backfill attempt using an older migration body can
|
||||
# be resumed safely.
|
||||
seen_emails: dict[str, int] = {}
|
||||
for user_id, email in conn.execute(sa.select(users.c.id, users.c.user).order_by(users.c.id)).all():
|
||||
normalized_email = str(email or '').strip().casefold()
|
||||
if not normalized_email:
|
||||
raise RuntimeError(f'Cannot normalize empty account identity for user row {user_id}')
|
||||
if len(normalized_email) > 320:
|
||||
raise RuntimeError(
|
||||
f'Cannot normalize account identity for user row {user_id}: canonical value exceeds 320 characters'
|
||||
)
|
||||
duplicate_user_id = seen_emails.get(normalized_email)
|
||||
if duplicate_user_id is not None:
|
||||
raise RuntimeError(
|
||||
f'Cannot create normalized account identity: user rows '
|
||||
f'{duplicate_user_id} and {user_id} both normalize to {normalized_email!r}'
|
||||
)
|
||||
seen_emails[normalized_email] = user_id
|
||||
conn.execute(users.update().where(users.c.id == user_id).values(normalized_email=normalized_email))
|
||||
|
||||
columns = _columns(conn, 'users')
|
||||
checks = {
|
||||
constraint.get('name'): constraint
|
||||
for constraint in _inspector(conn).get_check_constraints('users')
|
||||
if constraint.get('name') is not None
|
||||
}
|
||||
identity_check = checks.get('ck_users_normalized_email')
|
||||
identity_check_sql = str((identity_check or {}).get('sqltext') or '').casefold()
|
||||
# Python casefold is the canonical identity algorithm. SQL ``lower`` is
|
||||
# dialect/locale dependent (notably Cherokee folds to uppercase in Python
|
||||
# but PostgreSQL lowercases it), so the database validates only portable
|
||||
# structural invariants and uniqueness.
|
||||
replace_legacy_identity_check = identity_check is not None and 'lower' in identity_check_sql
|
||||
needs_contract = columns['normalized_email']['nullable'] or identity_check is None or replace_legacy_identity_check
|
||||
if needs_contract:
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
if replace_legacy_identity_check:
|
||||
batch_op.drop_constraint('ck_users_normalized_email', type_='check')
|
||||
if columns['normalized_email']['nullable']:
|
||||
batch_op.alter_column(
|
||||
'normalized_email',
|
||||
existing_type=columns['normalized_email']['type'],
|
||||
nullable=False,
|
||||
)
|
||||
if identity_check is None or replace_legacy_identity_check:
|
||||
batch_op.create_check_constraint(
|
||||
'ck_users_normalized_email',
|
||||
'normalized_email = trim(normalized_email) '
|
||||
'AND length(normalized_email) > 0 '
|
||||
'AND length(normalized_email) <= 320',
|
||||
)
|
||||
if 'uq_users_normalized_email' not in _index_names(conn, 'users'):
|
||||
op.create_index('uq_users_normalized_email', 'users', ['normalized_email'], unique=True)
|
||||
|
||||
|
||||
def _api_key_owner(conn: sa.Connection, workspace_uuid: str | None) -> str | None:
|
||||
if workspace_uuid is None or 'workspace_memberships' not in _table_names(conn):
|
||||
return None
|
||||
memberships = sa.table(
|
||||
'workspace_memberships',
|
||||
sa.column('workspace_uuid', sa.String(36)),
|
||||
sa.column('account_uuid', sa.String(36)),
|
||||
sa.column('role', sa.String(32)),
|
||||
)
|
||||
return conn.execute(
|
||||
sa.select(memberships.c.account_uuid)
|
||||
.where(
|
||||
memberships.c.workspace_uuid == workspace_uuid,
|
||||
memberships.c.role == 'owner',
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _expand_and_hash_api_keys(conn: sa.Connection, workspace_uuid: str | None) -> None:
|
||||
if 'api_keys' not in _table_names(conn):
|
||||
return
|
||||
columns = _columns(conn, 'api_keys')
|
||||
additions = (
|
||||
('uuid', sa.Column('uuid', sa.String(36), nullable=True)),
|
||||
('created_by_account_uuid', sa.Column('created_by_account_uuid', sa.String(36), nullable=True)),
|
||||
('key_hash', sa.Column('key_hash', sa.String(64), nullable=True)),
|
||||
('scopes', sa.Column('scopes', sa.JSON(), nullable=True)),
|
||||
('status', sa.Column('status', sa.String(32), nullable=True, server_default='active')),
|
||||
('expires_at', sa.Column('expires_at', sa.DateTime(), nullable=True)),
|
||||
('last_used_at', sa.Column('last_used_at', sa.DateTime(), nullable=True)),
|
||||
)
|
||||
for name, column in additions:
|
||||
if name not in columns:
|
||||
op.add_column('api_keys', column)
|
||||
|
||||
columns = _columns(conn, 'api_keys')
|
||||
api_key_columns = [sa.column('id', sa.Integer())]
|
||||
for name in ('uuid', 'created_by_account_uuid', 'key_hash', 'scopes', 'status', 'key'):
|
||||
if name in columns:
|
||||
api_key_columns.append(sa.column(name, columns[name]['type']))
|
||||
api_keys = sa.table('api_keys', *api_key_columns)
|
||||
owner_uuid = _api_key_owner(conn, workspace_uuid)
|
||||
selected_columns = [api_keys.c.id, api_keys.c.uuid, api_keys.c.key_hash]
|
||||
if 'key' in api_keys.c:
|
||||
selected_columns.append(api_keys.c.key)
|
||||
rows = conn.execute(sa.select(*selected_columns).order_by(api_keys.c.id)).mappings().all()
|
||||
for row in rows:
|
||||
values: dict[str, object] = {}
|
||||
if not row['uuid']:
|
||||
values['uuid'] = str(uuid.uuid4())
|
||||
if not row['key_hash']:
|
||||
plaintext = row.get('key')
|
||||
if not isinstance(plaintext, str) or not plaintext:
|
||||
raise RuntimeError(f'API key row {row["id"]} has no secret to hash')
|
||||
values['key_hash'] = hashlib.sha256(plaintext.encode()).hexdigest()
|
||||
if values:
|
||||
conn.execute(api_keys.update().where(api_keys.c.id == row['id']).values(**values))
|
||||
# Pre-tenancy API keys historically had unrestricted instance access. A
|
||||
# wildcard preserves that behavior while binding it to the backfilled
|
||||
# Workspace; new keys must store their requested explicit scopes.
|
||||
conn.execute(api_keys.update().where(api_keys.c.scopes.is_(None)).values(scopes=['*']))
|
||||
conn.execute(api_keys.update().where(api_keys.c.status.is_(None)).values(status='active'))
|
||||
if owner_uuid is not None:
|
||||
conn.execute(
|
||||
api_keys.update()
|
||||
.where(api_keys.c.created_by_account_uuid.is_(None))
|
||||
.values(created_by_account_uuid=owner_uuid)
|
||||
)
|
||||
|
||||
columns = _columns(conn, 'api_keys')
|
||||
check_names = {constraint.get('name') for constraint in _inspector(conn).get_check_constraints('api_keys')}
|
||||
existing_fks = _inspector(conn).get_foreign_keys('api_keys')
|
||||
creator_fk_exists = any(
|
||||
tuple(foreign_key.get('constrained_columns') or ()) == ('created_by_account_uuid',)
|
||||
and foreign_key.get('referred_table') == 'users'
|
||||
and tuple(foreign_key.get('referred_columns') or ()) == ('uuid',)
|
||||
for foreign_key in existing_fks
|
||||
)
|
||||
has_legacy_key = 'key' in columns
|
||||
needs_contract = (
|
||||
any(columns[name]['nullable'] for name in ('uuid', 'key_hash', 'scopes', 'status'))
|
||||
or 'ck_api_keys_status' not in check_names
|
||||
or not creator_fk_exists
|
||||
or has_legacy_key
|
||||
)
|
||||
if needs_contract:
|
||||
naming = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
|
||||
with op.batch_alter_table('api_keys', naming_convention=naming) as batch_op:
|
||||
for name in ('uuid', 'key_hash', 'scopes', 'status'):
|
||||
if columns[name]['nullable']:
|
||||
batch_op.alter_column(name, existing_type=columns[name]['type'], nullable=False)
|
||||
if 'ck_api_keys_status' not in check_names:
|
||||
batch_op.create_check_constraint('ck_api_keys_status', "status IN ('active', 'revoked')")
|
||||
if not creator_fk_exists:
|
||||
batch_op.create_foreign_key(
|
||||
'fk_api_keys_created_by_account',
|
||||
'users',
|
||||
['created_by_account_uuid'],
|
||||
['uuid'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
if has_legacy_key:
|
||||
# Dropping the column also removes its old global plaintext
|
||||
# unique constraint/index during SQLite's batch rebuild.
|
||||
batch_op.drop_column('key')
|
||||
|
||||
|
||||
def _expand_workspace_columns(conn: sa.Connection, workspace_uuid: str | None) -> None:
|
||||
tables = _table_names(conn)
|
||||
for table_name in _TENANT_TABLES:
|
||||
if table_name not in tables:
|
||||
continue
|
||||
columns = _columns(conn, table_name)
|
||||
if 'workspace_uuid' not in columns:
|
||||
op.add_column(table_name, sa.Column('workspace_uuid', sa.String(36), nullable=True))
|
||||
tenant_table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
|
||||
null_count = conn.scalar(
|
||||
sa.select(sa.func.count()).select_from(tenant_table).where(tenant_table.c.workspace_uuid.is_(None))
|
||||
)
|
||||
if null_count:
|
||||
if workspace_uuid is None:
|
||||
raise RuntimeError(f'Cannot backfill {table_name}: the instance has no unique local Workspace')
|
||||
conn.execute(
|
||||
tenant_table.update()
|
||||
.where(tenant_table.c.workspace_uuid.is_(None))
|
||||
.values(workspace_uuid=workspace_uuid)
|
||||
)
|
||||
|
||||
|
||||
def _mark_legacy_vector_collections(conn: sa.Connection, workspace_uuid: str | None) -> None:
|
||||
"""Persist which pre-tenancy KBs must keep using ``collection_id``.
|
||||
|
||||
The marker is backfilled only when this migration introduces the column,
|
||||
or resumes while that newly added column is still nullable. A fresh
|
||||
schema already contains the non-null column with ``false`` as its default,
|
||||
so knowledge bases created under the scoped-vector contract can never be
|
||||
mistaken for legacy data during a later migration retry.
|
||||
"""
|
||||
|
||||
if 'knowledge_bases' not in _table_names(conn):
|
||||
return
|
||||
columns = _columns(conn, 'knowledge_bases')
|
||||
introduced = 'legacy_vector_collection' not in columns
|
||||
if introduced:
|
||||
op.add_column(
|
||||
'knowledge_bases',
|
||||
sa.Column('legacy_vector_collection', sa.Boolean(), nullable=True),
|
||||
)
|
||||
columns = _columns(conn, 'knowledge_bases')
|
||||
needs_legacy_backfill = introduced or columns['legacy_vector_collection']['nullable']
|
||||
|
||||
knowledge_bases = sa.table(
|
||||
'knowledge_bases',
|
||||
sa.column('collection_id', columns['collection_id']['type']),
|
||||
sa.column('legacy_vector_collection', sa.Boolean()),
|
||||
*((sa.column('workspace_uuid', columns['workspace_uuid']['type']),) if 'workspace_uuid' in columns else ()),
|
||||
)
|
||||
if needs_legacy_backfill and workspace_uuid is not None:
|
||||
legacy_filter = sa.and_(
|
||||
knowledge_bases.c.collection_id.is_not(None),
|
||||
sa.func.length(sa.func.trim(knowledge_bases.c.collection_id)) > 0,
|
||||
)
|
||||
if 'workspace_uuid' in knowledge_bases.c:
|
||||
# A partially migrated database may already have Workspace
|
||||
# columns. Never mark a projected cloud row as legacy.
|
||||
legacy_filter = sa.and_(
|
||||
legacy_filter,
|
||||
sa.or_(
|
||||
knowledge_bases.c.workspace_uuid.is_(None),
|
||||
knowledge_bases.c.workspace_uuid == workspace_uuid,
|
||||
),
|
||||
)
|
||||
conn.execute(knowledge_bases.update().where(legacy_filter).values(legacy_vector_collection=True))
|
||||
|
||||
conn.execute(
|
||||
knowledge_bases.update()
|
||||
.where(knowledge_bases.c.legacy_vector_collection.is_(None))
|
||||
.values(legacy_vector_collection=False)
|
||||
)
|
||||
columns = _columns(conn, 'knowledge_bases')
|
||||
if columns['legacy_vector_collection']['nullable']:
|
||||
with op.batch_alter_table('knowledge_bases') as batch_op:
|
||||
batch_op.alter_column(
|
||||
'legacy_vector_collection',
|
||||
existing_type=columns['legacy_vector_collection']['type'],
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
|
||||
|
||||
def _drop_legacy_uniqueness(conn: sa.Connection) -> None:
|
||||
if 'bot_admins' in _table_names(conn):
|
||||
for constraint in _inspector(conn).get_unique_constraints('bot_admins'):
|
||||
if tuple(constraint.get('column_names') or ()) == ('bot_uuid', 'launcher_type', 'launcher_id'):
|
||||
with op.batch_alter_table('bot_admins') as batch_op:
|
||||
batch_op.drop_constraint(constraint['name'], type_='unique')
|
||||
break
|
||||
|
||||
if 'monitoring_feedback' in _table_names(conn):
|
||||
dropped_constraint = False
|
||||
for constraint in _inspector(conn).get_unique_constraints('monitoring_feedback'):
|
||||
if tuple(constraint.get('column_names') or ()) == ('feedback_id',):
|
||||
convention = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
|
||||
constraint_name = constraint.get('name') or 'uq_monitoring_feedback_feedback_id'
|
||||
with op.batch_alter_table(
|
||||
'monitoring_feedback',
|
||||
naming_convention=convention,
|
||||
) as batch_op:
|
||||
batch_op.drop_constraint(constraint_name, type_='unique')
|
||||
dropped_constraint = True
|
||||
break
|
||||
if not dropped_constraint:
|
||||
for index in _inspector(conn).get_indexes('monitoring_feedback'):
|
||||
if index.get('unique') and tuple(index.get('column_names') or ()) == ('feedback_id',):
|
||||
op.drop_index(index['name'], table_name='monitoring_feedback')
|
||||
|
||||
|
||||
def _create_index_if_missing(
|
||||
conn: sa.Connection,
|
||||
table_name: str,
|
||||
name: str,
|
||||
columns: tuple[str, ...],
|
||||
unique: bool,
|
||||
predicate: sa.TextClause | None,
|
||||
) -> None:
|
||||
if name in _index_names(conn, table_name):
|
||||
return
|
||||
if unique and predicate is None and columns in _unique_column_sets(conn, table_name):
|
||||
return
|
||||
kwargs = {}
|
||||
if predicate is not None:
|
||||
kwargs = {'sqlite_where': predicate, 'postgresql_where': predicate}
|
||||
op.create_index(name, table_name, list(columns), unique=unique, **kwargs)
|
||||
|
||||
|
||||
def _validate_scoped_unique_data(conn: sa.Connection) -> None:
|
||||
checks = (
|
||||
('mcp_servers', ('workspace_uuid', 'name')),
|
||||
('knowledge_bases', ('workspace_uuid', 'collection_id')),
|
||||
)
|
||||
for table_name, column_names in checks:
|
||||
if table_name not in _table_names(conn):
|
||||
continue
|
||||
columns = _columns(conn, table_name)
|
||||
if not all(column_name in columns for column_name in column_names):
|
||||
continue
|
||||
table = sa.table(
|
||||
table_name,
|
||||
*(sa.column(column_name, columns[column_name]['type']) for column_name in column_names),
|
||||
)
|
||||
group_columns = [table.c[column_name] for column_name in column_names]
|
||||
query = sa.select(*group_columns, sa.func.count()).group_by(*group_columns).having(sa.func.count() > 1)
|
||||
if column_names[-1] == 'collection_id':
|
||||
query = query.where(group_columns[-1].is_not(None))
|
||||
duplicate = conn.execute(query.limit(1)).first()
|
||||
if duplicate is not None:
|
||||
raise RuntimeError(
|
||||
f'Cannot create scoped unique key on {table_name}{column_names}: duplicate {duplicate!r}'
|
||||
)
|
||||
|
||||
|
||||
def _create_parent_and_scoped_indexes(conn: sa.Connection) -> None:
|
||||
_validate_scoped_unique_data(conn)
|
||||
tables = _table_names(conn)
|
||||
for table_name, indexes in _SCOPED_INDEXES.items():
|
||||
if table_name not in tables:
|
||||
continue
|
||||
available_columns = _columns(conn, table_name)
|
||||
for name, columns, unique, predicate in indexes:
|
||||
if all(column in available_columns for column in columns):
|
||||
_create_index_if_missing(conn, table_name, name, columns, unique, predicate)
|
||||
|
||||
|
||||
def _contract_table(conn: sa.Connection, table_name: str) -> None:
|
||||
columns = _columns(conn, table_name)
|
||||
if 'workspace_uuid' not in columns:
|
||||
return
|
||||
direct_workspace_fk = _foreign_key_exists(
|
||||
conn,
|
||||
table_name,
|
||||
('workspace_uuid',),
|
||||
'workspaces',
|
||||
('uuid',),
|
||||
)
|
||||
current_pk = tuple(_inspector(conn).get_pk_constraint(table_name).get('constrained_columns') or ())
|
||||
desired_pk = _COMPOSITE_PRIMARY_KEYS.get(table_name)
|
||||
missing_composite_fks = [
|
||||
foreign_key
|
||||
for foreign_key in _COMPOSITE_FOREIGN_KEYS.get(table_name, ())
|
||||
if not _foreign_key_exists(
|
||||
conn,
|
||||
table_name,
|
||||
foreign_key[1],
|
||||
foreign_key[2],
|
||||
foreign_key[3],
|
||||
)
|
||||
]
|
||||
needs_contract = (
|
||||
columns['workspace_uuid']['nullable']
|
||||
or not direct_workspace_fk
|
||||
or (desired_pk is not None and current_pk != desired_pk)
|
||||
or bool(missing_composite_fks)
|
||||
)
|
||||
if not needs_contract:
|
||||
return
|
||||
|
||||
naming = {
|
||||
'pk': 'pk_%(table_name)s',
|
||||
'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
|
||||
}
|
||||
pk_name = _inspector(conn).get_pk_constraint(table_name).get('name') or f'pk_{table_name}'
|
||||
with op.batch_alter_table(table_name, naming_convention=naming) as batch_op:
|
||||
if columns['workspace_uuid']['nullable']:
|
||||
batch_op.alter_column(
|
||||
'workspace_uuid',
|
||||
existing_type=columns['workspace_uuid']['type'],
|
||||
nullable=False,
|
||||
)
|
||||
if desired_pk is not None and current_pk != desired_pk:
|
||||
batch_op.drop_constraint(pk_name, type_='primary')
|
||||
batch_op.create_primary_key(f'pk_{table_name}', list(desired_pk))
|
||||
if not direct_workspace_fk:
|
||||
batch_op.create_foreign_key(
|
||||
f'fk_{table_name}_workspace',
|
||||
'workspaces',
|
||||
['workspace_uuid'],
|
||||
['uuid'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
for name, local_columns, referred_table, referred_columns, ondelete in missing_composite_fks:
|
||||
batch_op.create_foreign_key(
|
||||
name,
|
||||
referred_table,
|
||||
list(local_columns),
|
||||
list(referred_columns),
|
||||
ondelete=ondelete,
|
||||
)
|
||||
|
||||
|
||||
def _contract_workspace_columns(conn: sa.Connection) -> None:
|
||||
tables = _table_names(conn)
|
||||
# Parents must be contracted before their children so SQLite can validate
|
||||
# the exact composite target key during a batch-table rebuild.
|
||||
order = (
|
||||
'api_keys',
|
||||
'bots',
|
||||
'bot_admins',
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
'legacy_pipelines',
|
||||
'pipeline_run_records',
|
||||
'plugin_settings',
|
||||
'knowledge_bases',
|
||||
'knowledge_base_files',
|
||||
'knowledge_base_chunks',
|
||||
'webhooks',
|
||||
'monitoring_messages',
|
||||
'monitoring_llm_calls',
|
||||
'monitoring_tool_calls',
|
||||
'monitoring_sessions',
|
||||
'monitoring_errors',
|
||||
'monitoring_embedding_calls',
|
||||
'monitoring_feedback',
|
||||
)
|
||||
for table_name in order:
|
||||
if table_name in tables:
|
||||
_contract_table(conn, table_name)
|
||||
|
||||
|
||||
def _migrate_workspace_metadata(conn: sa.Connection, workspace_uuid: str | None) -> None:
|
||||
tables = _table_names(conn)
|
||||
if 'workspaces' not in tables:
|
||||
return
|
||||
if 'workspace_metadata' not in tables:
|
||||
op.create_table(
|
||||
'workspace_metadata',
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('key', sa.String(255), nullable=False),
|
||||
sa.Column('value', sa.String(255), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid'],
|
||||
['workspaces.uuid'],
|
||||
name='fk_workspace_metadata_workspace',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.PrimaryKeyConstraint('workspace_uuid', 'key', name='pk_workspace_metadata'),
|
||||
)
|
||||
if workspace_uuid is None or 'metadata' not in tables:
|
||||
return
|
||||
metadata = sa.table(
|
||||
'metadata',
|
||||
sa.column('key', sa.String(255)),
|
||||
sa.column('value', sa.String(255)),
|
||||
)
|
||||
workspace_metadata = sa.table(
|
||||
'workspace_metadata',
|
||||
sa.column('workspace_uuid', sa.String(36)),
|
||||
sa.column('key', sa.String(255)),
|
||||
sa.column('value', sa.String(255)),
|
||||
)
|
||||
tenant_keys = ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')
|
||||
rows = conn.execute(sa.select(metadata.c.key, metadata.c.value).where(metadata.c.key.in_(tenant_keys))).all()
|
||||
for key, value in rows:
|
||||
exists = conn.execute(
|
||||
sa.select(workspace_metadata.c.key).where(
|
||||
workspace_metadata.c.workspace_uuid == workspace_uuid,
|
||||
workspace_metadata.c.key == key,
|
||||
)
|
||||
).first()
|
||||
if exists is None:
|
||||
conn.execute(
|
||||
workspace_metadata.insert().values(
|
||||
workspace_uuid=workspace_uuid,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
conn.execute(metadata.delete().where(metadata.c.key.in_(tenant_keys)))
|
||||
|
||||
|
||||
def _validate_contract(conn: sa.Connection) -> None:
|
||||
for table_name in _TENANT_TABLES:
|
||||
if table_name not in _table_names(conn):
|
||||
continue
|
||||
columns = _columns(conn, table_name)
|
||||
if 'workspace_uuid' not in columns or columns['workspace_uuid']['nullable']:
|
||||
raise RuntimeError(f'{table_name}.workspace_uuid was not contracted to NOT NULL')
|
||||
table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
|
||||
if conn.scalar(sa.select(sa.func.count()).select_from(table).where(table.c.workspace_uuid.is_(None))):
|
||||
raise RuntimeError(f'{table_name} still contains unscoped rows')
|
||||
if conn.dialect.name == 'sqlite':
|
||||
violations = conn.execute(sa.text('PRAGMA foreign_key_check')).all()
|
||||
if violations:
|
||||
raise RuntimeError(f'SQLite foreign key validation failed: {violations[:5]!r}')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_upgrade_normalized_email(conn)
|
||||
workspace_uuid = _default_workspace_uuid(conn)
|
||||
_mark_legacy_vector_collections(conn, workspace_uuid)
|
||||
_expand_workspace_columns(conn, workspace_uuid)
|
||||
_expand_and_hash_api_keys(conn, workspace_uuid)
|
||||
_drop_legacy_uniqueness(conn)
|
||||
_create_parent_and_scoped_indexes(conn)
|
||||
_contract_workspace_columns(conn)
|
||||
_migrate_workspace_metadata(conn, workspace_uuid)
|
||||
_validate_contract(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError(
|
||||
'0010_scope_resources is intentionally irreversible because plaintext API key secrets were securely removed'
|
||||
)
|
||||
@@ -47,6 +47,12 @@ def _do_stamp(connection: Connection, revision: str = 'head') -> None:
|
||||
command.stamp(cfg, revision)
|
||||
|
||||
|
||||
def _do_downgrade(connection: Connection, revision: str) -> None:
|
||||
"""Synchronous downgrade — runs inside run_sync."""
|
||||
cfg = _build_config(connection)
|
||||
command.downgrade(cfg, revision)
|
||||
|
||||
|
||||
def _do_get_current(connection: Connection) -> str | None:
|
||||
"""Get current alembic revision synchronously."""
|
||||
ctx = MigrationContext.configure(connection)
|
||||
@@ -73,6 +79,13 @@ async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def run_alembic_downgrade(async_engine: AsyncEngine, revision: str) -> None:
|
||||
"""Run Alembic downgrade to the given revision."""
|
||||
async with async_engine.connect() as conn:
|
||||
await conn.run_sync(_do_downgrade, revision)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
|
||||
"""Get current alembic revision, or None if not stamped."""
|
||||
async with async_engine.connect() as conn:
|
||||
@@ -121,6 +134,7 @@ if __name__ == '__main__':
|
||||
print('Commands:')
|
||||
print(' autogenerate "message" — Generate migration from ORM model diff')
|
||||
print(' upgrade [revision] — Upgrade database (default: head)')
|
||||
print(' downgrade <revision> — Downgrade database to a revision')
|
||||
print(' stamp [revision] — Stamp revision without running (default: head)')
|
||||
print(' current — Show current revision')
|
||||
sys.exit(1)
|
||||
@@ -140,6 +154,13 @@ if __name__ == '__main__':
|
||||
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
|
||||
asyncio.run(run_alembic_stamp(engine, rev))
|
||||
print(f'Stamped: {rev}')
|
||||
elif cmd == 'downgrade':
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: python -m langbot.pkg.persistence.alembic_runner downgrade <revision>')
|
||||
sys.exit(1)
|
||||
rev = sys.argv[2]
|
||||
asyncio.run(run_alembic_downgrade(engine, rev))
|
||||
print(f'Downgraded to: {rev}')
|
||||
elif cmd == 'current':
|
||||
rev = asyncio.run(get_alembic_current(engine))
|
||||
print(f'Current revision: {rev}')
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import sqlite3
|
||||
import typing
|
||||
|
||||
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
import sqlalchemy
|
||||
|
||||
from . import database, migration
|
||||
from . import database, migration, sqlite_migration_backup
|
||||
from ..entity.persistence import base, metadata, model as persistence_model
|
||||
from ..entity.persistence import workspace as persistence_workspace
|
||||
from ..entity import persistence
|
||||
from ..core import app
|
||||
from ..utils import constants, importutil
|
||||
@@ -19,6 +21,51 @@ importutil.import_modules_in_pkg(migrations)
|
||||
importutil.import_modules_in_pkg(persistence)
|
||||
|
||||
|
||||
_ALEMBIC_TENANT_TABLES = {
|
||||
'workspaces',
|
||||
'workspace_memberships',
|
||||
'workspace_invitations',
|
||||
'workspace_execution_states',
|
||||
'workspace_metadata',
|
||||
'api_keys',
|
||||
'bots',
|
||||
'bot_admins',
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
'legacy_pipelines',
|
||||
'pipeline_run_records',
|
||||
'plugin_settings',
|
||||
'knowledge_bases',
|
||||
'knowledge_base_files',
|
||||
'knowledge_base_chunks',
|
||||
'webhooks',
|
||||
'monitoring_messages',
|
||||
'monitoring_llm_calls',
|
||||
'monitoring_tool_calls',
|
||||
'monitoring_sessions',
|
||||
'monitoring_errors',
|
||||
'monitoring_embedding_calls',
|
||||
'monitoring_feedback',
|
||||
}
|
||||
|
||||
_PRE_WORKSPACE_ALEMBIC_REVISIONS = {
|
||||
'0001_baseline',
|
||||
'0002_sample',
|
||||
'0003_add_rerank_models',
|
||||
'0004_add_mcp_readme',
|
||||
'0005_add_llm_context_length',
|
||||
'0006_normalize_mcp_remote_mode',
|
||||
'0007_add_bot_admins',
|
||||
'0008_mcp_resource_prefs',
|
||||
}
|
||||
_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
|
||||
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
|
||||
|
||||
|
||||
class PersistenceManager:
|
||||
"""Persistence module manager"""
|
||||
|
||||
@@ -42,6 +89,8 @@ class PersistenceManager:
|
||||
await self.db.initialize()
|
||||
break
|
||||
|
||||
self._enable_sqlite_foreign_keys()
|
||||
|
||||
await self.create_tables()
|
||||
|
||||
# run migrations
|
||||
@@ -79,12 +128,33 @@ class PersistenceManager:
|
||||
# Run Alembic migrations (new migration system)
|
||||
await self._run_alembic_migrations()
|
||||
|
||||
# A legacy database may not contain tenant tables introduced by a
|
||||
# newer release. They were deliberately deferred before 0009 because
|
||||
# their Workspace/account FK targets did not exist yet; create them
|
||||
# now that the tenancy contract is in place.
|
||||
await self.create_tables()
|
||||
|
||||
await self.write_space_model_providers()
|
||||
|
||||
async def create_tables(self):
|
||||
# create tables
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
await conn.run_sync(self.meta.create_all)
|
||||
|
||||
def create_compatible_tables(sync_conn: sqlalchemy.Connection) -> None:
|
||||
inspector = sqlalchemy.inspect(sync_conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
legacy_users = 'users' in existing_tables and (
|
||||
'uuid' not in {column['name'] for column in inspector.get_columns('users')}
|
||||
or 'workspaces' not in existing_tables
|
||||
)
|
||||
# On a legacy installation, resource tables already exist
|
||||
# without workspace_uuid and Workspace itself references the
|
||||
# account UUID introduced by 0009. Alembic must expand those
|
||||
# tables before SQLAlchemy may create any new tenant table.
|
||||
excluded_tables = _ALEMBIC_TENANT_TABLES if legacy_users else set()
|
||||
tables_to_create = [table for table in self.meta.sorted_tables if table.name not in excluded_tables]
|
||||
self.meta.create_all(sync_conn, tables=tables_to_create)
|
||||
|
||||
await conn.run_sync(create_compatible_tables)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -101,15 +171,80 @@ class PersistenceManager:
|
||||
if row is None:
|
||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
||||
|
||||
await self._ensure_instance_uuid_metadata()
|
||||
|
||||
def _enable_sqlite_foreign_keys(self) -> None:
|
||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||
engine = self.get_db_engine()
|
||||
if engine.dialect.name != 'sqlite':
|
||||
return
|
||||
if getattr(self, '_sqlite_fk_listener_installed', False):
|
||||
return
|
||||
|
||||
def set_sqlite_pragma(dbapi_connection, _connection_record) -> None:
|
||||
# aiosqlite exposes the normal sqlite cursor API through its
|
||||
# SQLAlchemy adapter. Guard the direct sqlite type too for tests.
|
||||
if isinstance(dbapi_connection, sqlite3.Connection) or hasattr(dbapi_connection, 'cursor'):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute('PRAGMA foreign_keys=ON')
|
||||
cursor.close()
|
||||
|
||||
sqlalchemy.event.listen(engine.sync_engine, 'connect', set_sqlite_pragma)
|
||||
self._sqlite_fk_listener_installed = True
|
||||
|
||||
async def _ensure_instance_uuid_metadata(self) -> None:
|
||||
"""Persist the runtime instance identifier before tenant migrations run."""
|
||||
runtime_instance_uuid = constants.instance_id.strip()
|
||||
if not runtime_instance_uuid:
|
||||
raise RuntimeError('LangBot instance UUID is empty before persistence initialization')
|
||||
|
||||
result = await self.execute_async(
|
||||
sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
|
||||
)
|
||||
persisted_instance_uuid = result.scalar_one_or_none()
|
||||
|
||||
if persisted_instance_uuid is None:
|
||||
await self.execute_async(
|
||||
sqlalchemy.insert(metadata.Metadata).values(key='instance_uuid', value=runtime_instance_uuid)
|
||||
)
|
||||
return
|
||||
|
||||
if persisted_instance_uuid != runtime_instance_uuid:
|
||||
raise RuntimeError(
|
||||
'LangBot instance UUID does not match the value bound to this database: '
|
||||
f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
|
||||
)
|
||||
|
||||
async def write_space_model_providers(self):
|
||||
if constants.edition != 'community':
|
||||
# SaaS Workspace/provider linkage is explicit control-plane state;
|
||||
# a process-level compatibility provider must never be projected
|
||||
# into an arbitrary cloud Workspace.
|
||||
return
|
||||
|
||||
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
||||
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
||||
)
|
||||
|
||||
# write space model providers
|
||||
workspace_result = await self.execute_async(
|
||||
sqlalchemy.select(persistence_workspace.Workspace.uuid).where(
|
||||
persistence_workspace.Workspace.instance_uuid == constants.instance_id,
|
||||
persistence_workspace.Workspace.source == persistence_workspace.WorkspaceSource.LOCAL.value,
|
||||
)
|
||||
)
|
||||
workspace_uuids = workspace_result.scalars().all()
|
||||
if len(workspace_uuids) != 1:
|
||||
raise RuntimeError(
|
||||
f'The fixed LangBot Models provider requires exactly one local Workspace; found {len(workspace_uuids)}'
|
||||
)
|
||||
workspace_uuid = workspace_uuids[0]
|
||||
|
||||
# The compatibility Space provider belongs to the OSS singleton
|
||||
# Workspace. It must never be discovered or inserted globally.
|
||||
result = await self.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.requester == 'space-chat-completions'
|
||||
persistence_model.ModelProvider.workspace_uuid == workspace_uuid,
|
||||
persistence_model.ModelProvider.requester == 'space-chat-completions',
|
||||
)
|
||||
)
|
||||
exists_space_chat_completions_model_provider = result.first()
|
||||
@@ -119,6 +254,7 @@ class PersistenceManager:
|
||||
self.ap.logger.info('Creating space model providers...')
|
||||
space_chat_completions_model_provider = {
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'name': 'LangBot Models',
|
||||
'requester': 'space-chat-completions',
|
||||
'base_url': space_models_gateway_api_url,
|
||||
@@ -132,7 +268,10 @@ class PersistenceManager:
|
||||
if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:
|
||||
await self.execute_async(
|
||||
sqlalchemy.update(persistence_model.ModelProvider)
|
||||
.where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)
|
||||
.where(
|
||||
persistence_model.ModelProvider.workspace_uuid == workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid,
|
||||
)
|
||||
.values({'base_url': space_models_gateway_api_url})
|
||||
)
|
||||
|
||||
@@ -153,13 +292,67 @@ class PersistenceManager:
|
||||
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
|
||||
current_rev = '0001_baseline'
|
||||
|
||||
# Upgrade to head
|
||||
if engine.dialect.name == 'sqlite':
|
||||
if current_rev in _PRE_WORKSPACE_ALEMBIC_REVISIONS:
|
||||
await self._run_verified_sqlite_migration(
|
||||
engine,
|
||||
source_revision=current_rev,
|
||||
target_revision=_WORKSPACE_ALEMBIC_REVISION,
|
||||
)
|
||||
current_rev = await alembic_runner.get_alembic_current(engine)
|
||||
if current_rev == _WORKSPACE_ALEMBIC_REVISION:
|
||||
await self._run_verified_sqlite_migration(
|
||||
engine,
|
||||
source_revision=current_rev,
|
||||
target_revision=_RESOURCE_SCOPE_ALEMBIC_REVISION,
|
||||
)
|
||||
|
||||
# PostgreSQL has transactional DDL. SQLite has already crossed the
|
||||
# two destructive tenancy boundaries under verified backups; this
|
||||
# final call is a no-op today and applies future migrations.
|
||||
await alembic_runner.run_alembic_upgrade(engine, 'head')
|
||||
self.ap.logger.info('Alembic migrations completed.')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
|
||||
raise
|
||||
|
||||
async def _run_verified_sqlite_migration(
|
||||
self,
|
||||
engine: sqlalchemy_asyncio.AsyncEngine,
|
||||
*,
|
||||
source_revision: str,
|
||||
target_revision: str,
|
||||
) -> None:
|
||||
from . import alembic_runner
|
||||
|
||||
backup = await sqlite_migration_backup.create_verified_backup(
|
||||
engine,
|
||||
source_revision=source_revision,
|
||||
target_revision=target_revision,
|
||||
)
|
||||
self.ap.logger.info(f'Created verified SQLite migration backup {backup.backup_path} before {target_revision}.')
|
||||
try:
|
||||
await alembic_runner.run_alembic_upgrade(engine, target_revision)
|
||||
completed_revision = await alembic_runner.get_alembic_current(engine)
|
||||
if completed_revision != target_revision:
|
||||
raise RuntimeError(f'Alembic stopped at {completed_revision!r}, expected {target_revision!r}')
|
||||
await sqlite_migration_backup.mark_migration_succeeded(
|
||||
backup,
|
||||
completed_revision=completed_revision,
|
||||
)
|
||||
except BaseException:
|
||||
await sqlite_migration_backup.restore_verified_backup(engine, backup)
|
||||
restored_revision = await alembic_runner.get_alembic_current(engine)
|
||||
if restored_revision != source_revision:
|
||||
raise RuntimeError(
|
||||
f'SQLite migration recovery restored revision {restored_revision!r}, expected {source_revision!r}'
|
||||
)
|
||||
self.ap.logger.error(
|
||||
f'SQLite migration to {target_revision} failed; restored verified backup '
|
||||
f'{backup.backup_path} at revision {source_revision}.'
|
||||
)
|
||||
raise
|
||||
|
||||
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||
async with self.get_db_engine().connect() as conn:
|
||||
result = await conn.execute(*args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Durable SQLite backups for destructive Alembic migration boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import typing
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
|
||||
class SQLiteMigrationBackupError(RuntimeError):
|
||||
"""A verified migration backup could not be created or restored."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class SQLiteMigrationBackup:
|
||||
database_path: pathlib.Path
|
||||
backup_path: pathlib.Path
|
||||
manifest_path: pathlib.Path
|
||||
source_revision: str
|
||||
target_revision: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def _safe_label(value: str) -> str:
|
||||
label = re.sub(r'[^A-Za-z0-9_.-]+', '-', value).strip('-')
|
||||
return label or 'unknown'
|
||||
|
||||
|
||||
def _database_path(engine: AsyncEngine) -> pathlib.Path:
|
||||
if engine.dialect.name != 'sqlite':
|
||||
raise SQLiteMigrationBackupError('SQLite migration backups require a SQLite engine')
|
||||
database = engine.url.database
|
||||
if not database or database == ':memory:' or engine.url.query.get('mode') == 'memory':
|
||||
raise SQLiteMigrationBackupError('Tenant schema migrations require a file-backed SQLite database for recovery')
|
||||
database_path = pathlib.Path(database).expanduser()
|
||||
if not database_path.is_absolute():
|
||||
database_path = pathlib.Path.cwd() / database_path
|
||||
database_path = database_path.resolve()
|
||||
if not database_path.is_file():
|
||||
raise SQLiteMigrationBackupError(f'SQLite database does not exist: {database_path}')
|
||||
return database_path
|
||||
|
||||
|
||||
def _open_read_only(path: pathlib.Path) -> sqlite3.Connection:
|
||||
return sqlite3.connect(f'{path.as_uri()}?mode=ro', uri=True, timeout=30)
|
||||
|
||||
|
||||
def _read_revision(connection: sqlite3.Connection) -> str | None:
|
||||
has_version_table = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'"
|
||||
).fetchone()
|
||||
if has_version_table is None:
|
||||
return None
|
||||
rows = connection.execute('SELECT version_num FROM alembic_version').fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
if len(rows) != 1 or not isinstance(rows[0][0], str):
|
||||
raise SQLiteMigrationBackupError('SQLite backup has an invalid Alembic revision table')
|
||||
return rows[0][0]
|
||||
|
||||
|
||||
def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -> None:
|
||||
quick_check = connection.execute('PRAGMA quick_check').fetchall()
|
||||
if quick_check != [('ok',)]:
|
||||
raise SQLiteMigrationBackupError(f'SQLite quick_check failed: {quick_check[:5]!r}')
|
||||
actual_revision = _read_revision(connection)
|
||||
if actual_revision != expected_revision:
|
||||
raise SQLiteMigrationBackupError(
|
||||
f'SQLite backup revision mismatch: {actual_revision!r} != {expected_revision!r}'
|
||||
)
|
||||
|
||||
|
||||
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
|
||||
with _open_read_only(path) as connection:
|
||||
_verify_connection(connection, expected_revision)
|
||||
|
||||
|
||||
def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.Any) -> None:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'version': 1,
|
||||
'status': status,
|
||||
'created_at': backup.created_at,
|
||||
'database_path': str(backup.database_path),
|
||||
'backup_path': str(backup.backup_path),
|
||||
'source_revision': backup.source_revision,
|
||||
'target_revision': backup.target_revision,
|
||||
'quick_check': 'ok',
|
||||
**extra,
|
||||
}
|
||||
backup.manifest_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f'.{backup.manifest_path.name}.',
|
||||
suffix='.tmp',
|
||||
dir=backup.manifest_path.parent,
|
||||
)
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as file:
|
||||
json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
file.write('\n')
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.chmod(temporary_path, 0o600)
|
||||
os.replace(temporary_path, backup.manifest_path)
|
||||
_fsync_directory(backup.manifest_path.parent)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _fsync_file(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_directory(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _create_backup(
|
||||
database_path: pathlib.Path,
|
||||
source_revision: str,
|
||||
target_revision: str,
|
||||
) -> SQLiteMigrationBackup:
|
||||
backup_directory = database_path.parent / 'migration-backups'
|
||||
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(backup_directory, 0o700)
|
||||
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)}-'
|
||||
f'from-{_safe_label(source_revision)}-{created_at}-{secrets.token_hex(4)}'
|
||||
)
|
||||
backup_path = backup_directory / f'{stem}.sqlite3'
|
||||
manifest_path = backup_directory / f'{stem}.json'
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f'.{stem}.',
|
||||
suffix='.creating',
|
||||
dir=backup_directory,
|
||||
)
|
||||
os.close(descriptor)
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(database_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
):
|
||||
source.execute('PRAGMA busy_timeout = 30000')
|
||||
source.backup(destination)
|
||||
destination.commit()
|
||||
_verify_connection(destination, source_revision)
|
||||
os.chmod(temporary_path, 0o600)
|
||||
_fsync_file(temporary_path)
|
||||
os.replace(temporary_path, backup_path)
|
||||
_fsync_file(backup_path)
|
||||
_fsync_directory(backup_directory)
|
||||
backup = SQLiteMigrationBackup(
|
||||
database_path=database_path,
|
||||
backup_path=backup_path,
|
||||
manifest_path=manifest_path,
|
||||
source_revision=source_revision,
|
||||
target_revision=target_revision,
|
||||
created_at=created_at,
|
||||
)
|
||||
_write_manifest(backup, 'verified')
|
||||
return backup
|
||||
except Exception:
|
||||
backup_path.unlink(missing_ok=True)
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def create_verified_backup(
|
||||
engine: AsyncEngine,
|
||||
*,
|
||||
source_revision: str,
|
||||
target_revision: str,
|
||||
) -> SQLiteMigrationBackup:
|
||||
"""Create and verify an online-consistent backup next to instance data."""
|
||||
|
||||
database_path = _database_path(engine)
|
||||
return await asyncio.to_thread(
|
||||
_create_backup,
|
||||
database_path,
|
||||
source_revision,
|
||||
target_revision,
|
||||
)
|
||||
|
||||
|
||||
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
_verify_file(backup.backup_path, backup.source_revision)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
dir=backup.database_path.parent,
|
||||
)
|
||||
os.close(descriptor)
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(backup.backup_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
):
|
||||
source.backup(destination)
|
||||
destination.commit()
|
||||
_verify_connection(destination, backup.source_revision)
|
||||
os.chmod(temporary_path, 0o600)
|
||||
_fsync_file(temporary_path)
|
||||
|
||||
# A stale WAL could replay pages from the failed migration after the
|
||||
# main database file is replaced. The engine is disposed before this
|
||||
# function runs, so these exact sidecars are safe to remove.
|
||||
for suffix in ('-wal', '-shm', '-journal'):
|
||||
pathlib.Path(f'{backup.database_path}{suffix}').unlink(missing_ok=True)
|
||||
os.replace(temporary_path, backup.database_path)
|
||||
_fsync_file(backup.database_path)
|
||||
_fsync_directory(backup.database_path.parent)
|
||||
_verify_file(backup.database_path, backup.source_revision)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def restore_verified_backup(engine: AsyncEngine, backup: SQLiteMigrationBackup) -> None:
|
||||
"""Atomically restore a verified backup after a migration failure."""
|
||||
|
||||
await engine.dispose()
|
||||
await asyncio.to_thread(_restore_backup, backup)
|
||||
await asyncio.to_thread(
|
||||
_write_manifest,
|
||||
backup,
|
||||
'restored_after_failure',
|
||||
restored_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def mark_migration_succeeded(
|
||||
backup: SQLiteMigrationBackup,
|
||||
*,
|
||||
completed_revision: str,
|
||||
) -> None:
|
||||
"""Mark a retained verified backup after its migration boundary succeeds."""
|
||||
|
||||
await asyncio.to_thread(
|
||||
_write_manifest,
|
||||
backup,
|
||||
'migration_succeeded',
|
||||
completed_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
completed_revision=completed_revision,
|
||||
)
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Message Aggregator Module
|
||||
|
||||
This module provides message aggregation/debounce functionality.
|
||||
When users send multiple messages consecutively, the aggregator will wait
|
||||
for a configurable delay period and merge them into a single message
|
||||
before processing.
|
||||
"""
|
||||
"""Workspace-scoped message aggregation and debounce support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,96 +7,114 @@ import time
|
||||
import typing
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from .pool import ExecutionContextMismatchError
|
||||
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core import app
|
||||
|
||||
# Maximum number of messages to buffer before forcing a flush
|
||||
MAX_BUFFER_MESSAGES = 10
|
||||
|
||||
AggregationKey = tuple[
|
||||
str,
|
||||
str,
|
||||
int,
|
||||
str,
|
||||
str | None,
|
||||
str,
|
||||
int | str,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingMessage:
|
||||
"""A pending message waiting to be aggregated"""
|
||||
"""A pending message carrying its trusted execution scope."""
|
||||
|
||||
execution_context: ExecutionContext
|
||||
bot_uuid: str
|
||||
launcher_type: provider_session.LauncherTypes
|
||||
launcher_id: typing.Union[int, str]
|
||||
sender_id: typing.Union[int, str]
|
||||
launcher_id: int | str
|
||||
sender_id: int | str
|
||||
message_event: platform_events.MessageEvent
|
||||
message_chain: platform_message.MessageChain
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
pipeline_uuid: typing.Optional[str]
|
||||
pipeline_uuid: str | None
|
||||
routed_by_rule: bool = False
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionBuffer:
|
||||
"""Buffer for a single session's pending messages"""
|
||||
"""Pending messages for one scoped aggregation key."""
|
||||
|
||||
session_id: str
|
||||
aggregation_key: AggregationKey
|
||||
execution_context: ExecutionContext
|
||||
messages: list[PendingMessage] = field(default_factory=list)
|
||||
timer_task: typing.Optional[asyncio.Task] = None
|
||||
timer_task: asyncio.Task | None = None
|
||||
last_message_time: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class MessageAggregator:
|
||||
"""Message aggregator that buffers and merges consecutive messages
|
||||
|
||||
This class implements a debounce mechanism for incoming messages.
|
||||
When a message arrives, it starts a timer. If more messages arrive
|
||||
before the timer expires, they are buffered. When the timer expires,
|
||||
all buffered messages are merged and sent to the query pool.
|
||||
"""
|
||||
"""Debounce consecutive messages without crossing Workspace boundaries."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
buffers: dict[str, SessionBuffer]
|
||||
"""Session ID -> SessionBuffer mapping"""
|
||||
|
||||
buffers: dict[AggregationKey, SessionBuffer]
|
||||
lock: asyncio.Lock
|
||||
"""Lock for thread-safe buffer operations"""
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self.buffers = {}
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
def _get_session_id(
|
||||
def _get_aggregation_key(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
bot_uuid: str,
|
||||
launcher_type: provider_session.LauncherTypes,
|
||||
launcher_id: typing.Union[int, str],
|
||||
) -> str:
|
||||
"""Generate a unique session ID"""
|
||||
return f'{bot_uuid}:{launcher_type.value}:{launcher_id}'
|
||||
launcher_id: int | str,
|
||||
pipeline_uuid: str | None,
|
||||
) -> AggregationKey:
|
||||
"""Build a key that cannot alias another Workspace, bot, or pipeline."""
|
||||
|
||||
async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]:
|
||||
"""Get aggregation configuration for a pipeline
|
||||
return (
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
execution_context.placement_generation,
|
||||
bot_uuid,
|
||||
pipeline_uuid,
|
||||
launcher_type.value,
|
||||
launcher_id,
|
||||
)
|
||||
|
||||
async def _get_aggregation_config(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
pipeline_uuid: str | None,
|
||||
) -> tuple[bool, float]:
|
||||
"""Return aggregation enablement and a clamped debounce delay."""
|
||||
|
||||
Returns:
|
||||
tuple: (enabled, delay_seconds)
|
||||
"""
|
||||
default_enabled = False
|
||||
default_delay = 1.5
|
||||
|
||||
if pipeline_uuid is None:
|
||||
return default_enabled, default_delay
|
||||
|
||||
# Get pipeline from pipeline manager
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
|
||||
execution_context,
|
||||
pipeline_uuid,
|
||||
)
|
||||
if pipeline is None:
|
||||
return default_enabled, default_delay
|
||||
|
||||
config = pipeline.pipeline_entity.config or {}
|
||||
trigger_config = config.get('trigger', {})
|
||||
aggregation_config = trigger_config.get('message-aggregation', {})
|
||||
|
||||
enabled = aggregation_config.get('enabled', default_enabled)
|
||||
|
||||
delay_raw = aggregation_config.get('delay', default_delay)
|
||||
@@ -111,33 +123,31 @@ class MessageAggregator:
|
||||
except (TypeError, ValueError):
|
||||
delay = default_delay
|
||||
|
||||
# Clamp delay to valid range
|
||||
delay = max(1.0, min(10.0, delay))
|
||||
|
||||
return enabled, delay
|
||||
return enabled, max(1.0, min(10.0, delay))
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
launcher_type: provider_session.LauncherTypes,
|
||||
launcher_id: typing.Union[int, str],
|
||||
sender_id: typing.Union[int, str],
|
||||
launcher_id: int | str,
|
||||
sender_id: int | str,
|
||||
message_event: platform_events.MessageEvent,
|
||||
message_chain: platform_message.MessageChain,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
pipeline_uuid: str | None = None,
|
||||
routed_by_rule: bool = False,
|
||||
execution_context: ExecutionContext | None = None,
|
||||
) -> None:
|
||||
"""Add a message to the aggregation buffer
|
||||
"""Buffer or directly enqueue a message in its trusted Workspace."""
|
||||
|
||||
If aggregation is disabled for the pipeline, the message is sent
|
||||
directly to the query pool. Otherwise, it's buffered and will be
|
||||
merged with other messages from the same session.
|
||||
"""
|
||||
enabled, delay = await self._get_aggregation_config(pipeline_uuid)
|
||||
execution_context = await self.ap.query_pool.resolve_execution_context(
|
||||
execution_context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
enabled, delay = await self._get_aggregation_config(execution_context, pipeline_uuid)
|
||||
|
||||
if not enabled:
|
||||
# Aggregation disabled, send directly to query pool
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=launcher_type,
|
||||
@@ -148,12 +158,19 @@ class MessageAggregator:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
return
|
||||
|
||||
session_id = self._get_session_id(bot_uuid, launcher_type, launcher_id)
|
||||
|
||||
aggregation_key = self._get_aggregation_key(
|
||||
execution_context,
|
||||
bot_uuid,
|
||||
launcher_type,
|
||||
launcher_id,
|
||||
pipeline_uuid,
|
||||
)
|
||||
pending_msg = PendingMessage(
|
||||
execution_context=execution_context,
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
@@ -167,106 +184,122 @@ class MessageAggregator:
|
||||
|
||||
force_flush = False
|
||||
async with self.lock:
|
||||
if session_id in self.buffers:
|
||||
buffer = self.buffers[session_id]
|
||||
# Cancel existing timer (just cancel, don't await inside lock)
|
||||
buffer = self.buffers.get(aggregation_key)
|
||||
if buffer is None:
|
||||
buffer = SessionBuffer(
|
||||
aggregation_key=aggregation_key,
|
||||
execution_context=execution_context,
|
||||
messages=[pending_msg],
|
||||
)
|
||||
self.buffers[aggregation_key] = buffer
|
||||
else:
|
||||
if buffer.execution_context != execution_context:
|
||||
raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key')
|
||||
if buffer.timer_task and not buffer.timer_task.done():
|
||||
buffer.timer_task.cancel()
|
||||
buffer.messages.append(pending_msg)
|
||||
else:
|
||||
buffer = SessionBuffer(
|
||||
session_id=session_id,
|
||||
messages=[pending_msg],
|
||||
)
|
||||
self.buffers[session_id] = buffer
|
||||
|
||||
buffer.last_message_time = time.time()
|
||||
|
||||
# Check if buffer reached max capacity
|
||||
if len(buffer.messages) >= MAX_BUFFER_MESSAGES:
|
||||
force_flush = True
|
||||
else:
|
||||
# Start new timer
|
||||
buffer.timer_task = asyncio.create_task(self._delayed_flush(session_id, delay))
|
||||
buffer.timer_task = asyncio.create_task(self._delayed_flush(aggregation_key, delay, execution_context))
|
||||
|
||||
if force_flush:
|
||||
await self._flush_buffer(session_id)
|
||||
await self._flush_buffer(aggregation_key, execution_context)
|
||||
|
||||
async def _delayed_flush(
|
||||
self,
|
||||
aggregation_key: AggregationKey,
|
||||
delay: float,
|
||||
execution_context: ExecutionContext,
|
||||
) -> None:
|
||||
"""Flush after the debounce delay using the captured context."""
|
||||
|
||||
async def _delayed_flush(self, session_id: str, delay: float) -> None:
|
||||
"""Wait for delay then flush the buffer"""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await self._flush_buffer(session_id)
|
||||
await self._flush_buffer(aggregation_key, execution_context)
|
||||
except asyncio.CancelledError:
|
||||
# Timer was cancelled, new message arrived
|
||||
pass
|
||||
|
||||
async def _flush_buffer(self, session_id: str) -> None:
|
||||
"""Flush the buffer for a session, merging all messages"""
|
||||
async with self.lock:
|
||||
buffer = self.buffers.pop(session_id, None)
|
||||
|
||||
if buffer is None or not buffer.messages:
|
||||
return
|
||||
|
||||
if len(buffer.messages) == 1:
|
||||
# Only one message, no need to merge
|
||||
msg = buffer.messages[0]
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=msg.bot_uuid,
|
||||
launcher_type=msg.launcher_type,
|
||||
launcher_id=msg.launcher_id,
|
||||
sender_id=msg.sender_id,
|
||||
message_event=msg.message_event,
|
||||
message_chain=msg.message_chain,
|
||||
adapter=msg.adapter,
|
||||
pipeline_uuid=msg.pipeline_uuid,
|
||||
routed_by_rule=msg.routed_by_rule,
|
||||
except WorkspaceError as exc:
|
||||
self.ap.logger.info(
|
||||
f'Dropped an aggregated message because its Workspace execution binding is stale: {exc}'
|
||||
)
|
||||
|
||||
async def _flush_buffer(
|
||||
self,
|
||||
aggregation_key: AggregationKey,
|
||||
execution_context: ExecutionContext,
|
||||
) -> None:
|
||||
"""Flush one buffer only when the captured scope still matches."""
|
||||
|
||||
async with self.lock:
|
||||
buffer = self.buffers.get(aggregation_key)
|
||||
if buffer is None:
|
||||
return
|
||||
if buffer.execution_context != execution_context:
|
||||
raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer')
|
||||
self.buffers.pop(aggregation_key)
|
||||
|
||||
if not buffer.messages:
|
||||
return
|
||||
|
||||
# Merge multiple messages
|
||||
merged_msg = self._merge_messages(buffer.messages)
|
||||
message = buffer.messages[0] if len(buffer.messages) == 1 else self._merge_messages(buffer.messages)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise WorkspaceInvariantError('Aggregation buffer instance does not match the active Workspace binding')
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=merged_msg.bot_uuid,
|
||||
launcher_type=merged_msg.launcher_type,
|
||||
launcher_id=merged_msg.launcher_id,
|
||||
sender_id=merged_msg.sender_id,
|
||||
message_event=merged_msg.message_event,
|
||||
message_chain=merged_msg.message_chain,
|
||||
adapter=merged_msg.adapter,
|
||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||
routed_by_rule=merged_msg.routed_by_rule,
|
||||
bot_uuid=message.bot_uuid,
|
||||
launcher_type=message.launcher_type,
|
||||
launcher_id=message.launcher_id,
|
||||
sender_id=message.sender_id,
|
||||
message_event=message.message_event,
|
||||
message_chain=message.message_chain,
|
||||
adapter=message.adapter,
|
||||
pipeline_uuid=message.pipeline_uuid,
|
||||
routed_by_rule=message.routed_by_rule,
|
||||
execution_context=message.execution_context,
|
||||
)
|
||||
|
||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||
"""Merge multiple messages into one
|
||||
"""Merge message chains after proving all messages share one scope."""
|
||||
|
||||
The merged message uses the first message as base and combines
|
||||
all message chains with newline separators.
|
||||
The original message_event is kept unmodified to preserve
|
||||
message metadata (message_id, etc.) for reply/quote.
|
||||
"""
|
||||
if not messages:
|
||||
raise ValueError('At least one pending message is required')
|
||||
if len(messages) == 1:
|
||||
return messages[0]
|
||||
|
||||
base_msg = messages[0]
|
||||
base_key = self._get_aggregation_key(
|
||||
base_msg.execution_context,
|
||||
base_msg.bot_uuid,
|
||||
base_msg.launcher_type,
|
||||
base_msg.launcher_id,
|
||||
base_msg.pipeline_uuid,
|
||||
)
|
||||
for message in messages[1:]:
|
||||
message_key = self._get_aggregation_key(
|
||||
message.execution_context,
|
||||
message.bot_uuid,
|
||||
message.launcher_type,
|
||||
message.launcher_id,
|
||||
message.pipeline_uuid,
|
||||
)
|
||||
if message_key != base_key or message.execution_context != base_msg.execution_context:
|
||||
raise ExecutionContextMismatchError('Cannot merge pending messages from different execution scopes')
|
||||
|
||||
# Build merged message chain
|
||||
merged_chain = platform_message.MessageChain([])
|
||||
|
||||
for i, msg in enumerate(messages):
|
||||
if i > 0:
|
||||
# Add newline separator between messages
|
||||
for index, message in enumerate(messages):
|
||||
if index > 0:
|
||||
merged_chain.append(platform_message.Plain(text='\n'))
|
||||
|
||||
# Copy all components from this message
|
||||
for component in msg.message_chain:
|
||||
for component in message.message_chain:
|
||||
merged_chain.append(component)
|
||||
|
||||
# Keep message_event unmodified (preserves original message_id and
|
||||
# metadata for reply/quote), only pass merged chain separately
|
||||
return PendingMessage(
|
||||
execution_context=base_msg.execution_context,
|
||||
bot_uuid=base_msg.bot_uuid,
|
||||
launcher_type=base_msg.launcher_type,
|
||||
launcher_id=base_msg.launcher_id,
|
||||
@@ -275,22 +308,23 @@ class MessageAggregator:
|
||||
message_chain=merged_chain,
|
||||
adapter=base_msg.adapter,
|
||||
pipeline_uuid=base_msg.pipeline_uuid,
|
||||
routed_by_rule=any(msg.routed_by_rule for msg in messages),
|
||||
routed_by_rule=any(message.routed_by_rule for message in messages),
|
||||
)
|
||||
|
||||
async def flush_all(self) -> None:
|
||||
"""Flush all pending buffers immediately
|
||||
"""Flush all pending buffers without dropping their captured scopes."""
|
||||
|
||||
This is useful during shutdown to ensure no messages are lost.
|
||||
"""
|
||||
# Snapshot session IDs and cancel all timers under lock
|
||||
async with self.lock:
|
||||
session_ids = list(self.buffers.keys())
|
||||
for sid in session_ids:
|
||||
buffer = self.buffers.get(sid)
|
||||
if buffer and buffer.timer_task and not buffer.timer_task.done():
|
||||
pending_buffers = [(key, buffer.execution_context) for key, buffer in self.buffers.items()]
|
||||
for buffer in self.buffers.values():
|
||||
if buffer.timer_task and not buffer.timer_task.done():
|
||||
buffer.timer_task.cancel()
|
||||
|
||||
# Flush each buffer outside the lock
|
||||
for session_id in session_ids:
|
||||
await self._flush_buffer(session_id)
|
||||
for aggregation_key, execution_context in pending_buffers:
|
||||
try:
|
||||
await self._flush_buffer(aggregation_key, execution_context)
|
||||
except WorkspaceError as exc:
|
||||
self.ap.logger.info(
|
||||
'Dropped an aggregated message during shutdown because its '
|
||||
f'Workspace execution binding is stale: {exc}'
|
||||
)
|
||||
|
||||
@@ -5,8 +5,10 @@ import traceback
|
||||
|
||||
from ..core import app
|
||||
from ..core import entities as core_entities
|
||||
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from .pool import get_query_execution_context
|
||||
|
||||
|
||||
class Controller:
|
||||
@@ -21,6 +23,52 @@ class Controller:
|
||||
self.ap = ap
|
||||
self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline'])
|
||||
|
||||
async def _assert_query_execution_active(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
):
|
||||
"""Revalidate a queued query immediately before runtime work starts."""
|
||||
|
||||
execution_context = get_query_execution_context(query)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise WorkspaceInvariantError('Queued query instance does not match the active Workspace binding')
|
||||
return execution_context
|
||||
|
||||
async def _process_query(self, selected_query: pipeline_query.Query) -> None:
|
||||
"""Run one selected query and always release its scheduling slot."""
|
||||
|
||||
try:
|
||||
async with self.semaphore:
|
||||
execution_context = await self._assert_query_execution_active(selected_query)
|
||||
pipeline_uuid = selected_query.pipeline_uuid
|
||||
|
||||
if pipeline_uuid:
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
|
||||
execution_context,
|
||||
pipeline_uuid,
|
||||
)
|
||||
if pipeline:
|
||||
await pipeline.run(selected_query)
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||
)
|
||||
else:
|
||||
self.ap.logger.warning(f'No pipeline_uuid for query {selected_query.query_id}, query dropped')
|
||||
except WorkspaceError as exc:
|
||||
self.ap.logger.info(
|
||||
f'Dropped query {selected_query.query_id} because its Workspace execution binding is stale: {exc}'
|
||||
)
|
||||
finally:
|
||||
await self.ap.query_pool.remove_query(selected_query)
|
||||
async with self.ap.query_pool:
|
||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||
self.ap.query_pool.condition.notify_all()
|
||||
|
||||
async def consumer(self):
|
||||
"""事件处理循环"""
|
||||
try:
|
||||
@@ -51,40 +99,18 @@ class Controller:
|
||||
continue
|
||||
|
||||
if selected_query:
|
||||
|
||||
async def _process_query(selected_query: pipeline_query.Query):
|
||||
async with self.semaphore: # 总并发上限
|
||||
# find pipeline
|
||||
# Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one.
|
||||
# Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected.
|
||||
pipeline_uuid = selected_query.pipeline_uuid
|
||||
|
||||
if pipeline_uuid:
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||
if pipeline:
|
||||
await pipeline.run(selected_query)
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||
)
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
|
||||
)
|
||||
|
||||
async with self.ap.query_pool:
|
||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||
# 通知其他协程,有新的请求可以处理了
|
||||
self.ap.query_pool.condition.notify_all()
|
||||
|
||||
execution_context = get_query_execution_context(selected_query)
|
||||
self.ap.task_mgr.create_task(
|
||||
_process_query(selected_query),
|
||||
self._process_query(selected_query),
|
||||
kind='query',
|
||||
name=f'query-{selected_query.query_id}',
|
||||
scopes=[
|
||||
core_entities.LifecycleControlScope.APPLICATION,
|
||||
core_entities.LifecycleControlScope.PLATFORM,
|
||||
],
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -15,6 +15,8 @@ if typing.TYPE_CHECKING:
|
||||
from ..core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
from .pool import get_query_execution_context
|
||||
|
||||
|
||||
class MonitoringHelper:
|
||||
"""Helper class for monitoring operations"""
|
||||
@@ -54,6 +56,7 @@ class MonitoringHelper:
|
||||
# Here we just record None, the full variables will be set when query completes
|
||||
|
||||
message_id = await ap.monitoring_service.record_message(
|
||||
get_query_execution_context(query),
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
@@ -74,6 +77,7 @@ class MonitoringHelper:
|
||||
# Update session activity or create new session if it doesn't exist
|
||||
# Always pass pipeline info to handle pipeline switches
|
||||
session_updated = await ap.monitoring_service.update_session_activity(
|
||||
get_query_execution_context(query),
|
||||
session_id,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
@@ -81,6 +85,7 @@ class MonitoringHelper:
|
||||
if not session_updated:
|
||||
# Session doesn't exist, create it
|
||||
await ap.monitoring_service.record_session_start(
|
||||
get_query_execution_context(query),
|
||||
session_id=session_id,
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
@@ -118,6 +123,7 @@ class MonitoringHelper:
|
||||
pass
|
||||
|
||||
await ap.monitoring_service.update_message_status(
|
||||
get_query_execution_context(query),
|
||||
message_id=message_id,
|
||||
status='success',
|
||||
variables=query_variables_str,
|
||||
@@ -170,6 +176,7 @@ class MonitoringHelper:
|
||||
return # No response to record
|
||||
|
||||
await ap.monitoring_service.record_message(
|
||||
get_query_execution_context(query),
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
@@ -215,6 +222,7 @@ class MonitoringHelper:
|
||||
|
||||
# Record error message
|
||||
message_id = await ap.monitoring_service.record_message(
|
||||
get_query_execution_context(query),
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
@@ -233,6 +241,7 @@ class MonitoringHelper:
|
||||
|
||||
# Record error log
|
||||
await ap.monitoring_service.record_error(
|
||||
get_query_execution_context(query),
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
@@ -271,6 +280,7 @@ class MonitoringHelper:
|
||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
await ap.monitoring_service.record_llm_call(
|
||||
get_query_execution_context(query),
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
@@ -13,7 +14,11 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ..utils import importutil
|
||||
from ..api.http.authz import WorkspaceRequiredError
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||||
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
from .config_coercion import coerce_pipeline_config
|
||||
from .pool import get_query_execution_context
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -82,15 +87,39 @@ class RuntimePipeline:
|
||||
enable_all_mcp_servers: bool
|
||||
"""是否启用所有MCP服务器"""
|
||||
|
||||
execution_context: ExecutionContext
|
||||
|
||||
workspace_uuid: str
|
||||
|
||||
placement_generation: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: app.Application,
|
||||
pipeline_entity: persistence_pipeline.LegacyPipeline,
|
||||
stage_containers: list[StageInstContainer],
|
||||
execution_context: ExecutionContext,
|
||||
):
|
||||
if not isinstance(execution_context, ExecutionContext):
|
||||
raise WorkspaceRequiredError('RuntimePipeline requires an ExecutionContext')
|
||||
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
|
||||
raise WorkspaceRequiredError('RuntimePipeline requires an instance and Workspace')
|
||||
if execution_context.placement_generation <= 0:
|
||||
raise WorkspaceRequiredError('RuntimePipeline requires a positive placement generation')
|
||||
if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceRequiredError('RuntimePipeline entity Workspace does not match its ExecutionContext')
|
||||
if execution_context.pipeline_uuid not in (None, pipeline_entity.uuid):
|
||||
raise WorkspaceRequiredError('RuntimePipeline UUID does not match its ExecutionContext')
|
||||
|
||||
self.ap = ap
|
||||
self.pipeline_entity = pipeline_entity
|
||||
self.stage_containers = stage_containers
|
||||
self.execution_context = dataclasses.replace(
|
||||
execution_context,
|
||||
pipeline_uuid=pipeline_entity.uuid,
|
||||
)
|
||||
self.workspace_uuid = self.execution_context.workspace_uuid
|
||||
self.placement_generation = self.execution_context.placement_generation
|
||||
|
||||
# Extract bound plugins and MCP servers from extensions_preferences
|
||||
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
||||
@@ -120,7 +149,37 @@ class RuntimePipeline:
|
||||
mcp_server_list = extensions_prefs.get('mcp_servers', [])
|
||||
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
|
||||
|
||||
async def _assert_execution_active(
|
||||
self,
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> ExecutionContext:
|
||||
"""Fail closed when this runtime or query belongs to a stale placement."""
|
||||
|
||||
execution_context = self.execution_context if query is None else get_query_execution_context(query)
|
||||
if (
|
||||
execution_context.instance_uuid != self.execution_context.instance_uuid
|
||||
or execution_context.workspace_uuid != self.workspace_uuid
|
||||
or execution_context.placement_generation != self.placement_generation
|
||||
or execution_context.pipeline_uuid != self.pipeline_entity.uuid
|
||||
):
|
||||
raise WorkspaceInvariantError('Query execution scope does not match RuntimePipeline')
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
|
||||
return execution_context
|
||||
|
||||
async def run(self, query: pipeline_query.Query):
|
||||
if (
|
||||
query.instance_uuid != self.execution_context.instance_uuid
|
||||
or query.workspace_uuid != self.workspace_uuid
|
||||
or query.placement_generation != self.placement_generation
|
||||
or query.pipeline_uuid != self.pipeline_entity.uuid
|
||||
):
|
||||
raise WorkspaceRequiredError('Query execution scope does not match RuntimePipeline')
|
||||
await self._assert_execution_active(query)
|
||||
query.pipeline_config = self.pipeline_entity.config
|
||||
# Store bound plugins and MCP servers in query for filtering
|
||||
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
|
||||
@@ -134,7 +193,11 @@ class RuntimePipeline:
|
||||
bot_name = 'WebChat'
|
||||
if query.bot_uuid:
|
||||
try:
|
||||
bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False)
|
||||
bot = await self.ap.bot_service.get_bot(
|
||||
query.workspace_uuid,
|
||||
query.bot_uuid,
|
||||
include_secret=False,
|
||||
)
|
||||
if bot:
|
||||
bot_name = bot.get('name', 'Unknown')
|
||||
except Exception:
|
||||
@@ -150,6 +213,7 @@ class RuntimePipeline:
|
||||
|
||||
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
|
||||
"""检查输出"""
|
||||
await self._assert_execution_active(query)
|
||||
if result.user_notice:
|
||||
# 处理str类型
|
||||
|
||||
@@ -162,7 +226,9 @@ class RuntimePipeline:
|
||||
query.message_event, platform_events.GroupMessage
|
||||
):
|
||||
result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))
|
||||
if await query.adapter.is_stream_output_supported() and query.resp_messages:
|
||||
stream_output_supported = await query.adapter.is_stream_output_supported()
|
||||
await self._assert_execution_active(query)
|
||||
if stream_output_supported and query.resp_messages:
|
||||
await query.adapter.reply_message_chunk(
|
||||
message_source=query.message_event,
|
||||
bot_message=query.resp_messages[-1],
|
||||
@@ -186,6 +252,7 @@ class RuntimePipeline:
|
||||
query.variables['_monitoring_has_error'] = True
|
||||
# Record error to monitoring system
|
||||
try:
|
||||
await self._assert_execution_active(query)
|
||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||
message_id = query.variables.get('_monitoring_message_id', '')
|
||||
@@ -194,6 +261,7 @@ class RuntimePipeline:
|
||||
# Update message status to error
|
||||
if message_id:
|
||||
await self.ap.monitoring_service.update_message_status(
|
||||
get_query_execution_context(query),
|
||||
message_id=message_id,
|
||||
status='error',
|
||||
level='error',
|
||||
@@ -201,6 +269,7 @@ class RuntimePipeline:
|
||||
|
||||
# Record error log
|
||||
await self.ap.monitoring_service.record_error(
|
||||
get_query_execution_context(query),
|
||||
bot_id=query.bot_uuid or 'unknown',
|
||||
bot_name=bot_name,
|
||||
pipeline_id=self.pipeline_entity.uuid,
|
||||
@@ -242,6 +311,7 @@ class RuntimePipeline:
|
||||
i = stage_index
|
||||
|
||||
while i < len(self.stage_containers):
|
||||
await self._assert_execution_active(query)
|
||||
stage_container = self.stage_containers[i]
|
||||
|
||||
query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里
|
||||
@@ -250,6 +320,7 @@ class RuntimePipeline:
|
||||
|
||||
if isinstance(result, typing.Coroutine):
|
||||
result = await result
|
||||
await self._assert_execution_active(query)
|
||||
|
||||
if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果
|
||||
self.ap.logger.debug(
|
||||
@@ -265,7 +336,14 @@ class RuntimePipeline:
|
||||
elif isinstance(result, typing.AsyncGenerator): # 生成器
|
||||
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
|
||||
|
||||
async for sub_result in result:
|
||||
iterator = result.__aiter__()
|
||||
while True:
|
||||
await self._assert_execution_active(query)
|
||||
try:
|
||||
sub_result = await anext(iterator)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
await self._assert_execution_active(query)
|
||||
self.ap.logger.debug(
|
||||
f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
|
||||
)
|
||||
@@ -283,6 +361,7 @@ class RuntimePipeline:
|
||||
|
||||
async def process_query(self, query: pipeline_query.Query):
|
||||
"""处理请求"""
|
||||
await self._assert_execution_active(query)
|
||||
# Get monitoring metadata
|
||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||
@@ -310,6 +389,7 @@ class RuntimePipeline:
|
||||
query.variables['_monitoring_message_id'] = message_id
|
||||
# Notify adapter so it can map platform-specific IDs to monitoring message ID
|
||||
if hasattr(query.adapter, 'on_monitoring_message_created'):
|
||||
await self._assert_execution_active(query)
|
||||
await query.adapter.on_monitoring_message_created(query, message_id)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||
@@ -334,7 +414,9 @@ class RuntimePipeline:
|
||||
message_chain=query.message_chain,
|
||||
)
|
||||
|
||||
await self._assert_execution_active(query)
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||
await self._assert_execution_active(query)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
self.ap.logger.debug(
|
||||
@@ -349,6 +431,7 @@ class RuntimePipeline:
|
||||
# Record query success only if no error occurred during processing
|
||||
if not query.variables.get('_monitoring_has_error', False):
|
||||
try:
|
||||
await self._assert_execution_active(query)
|
||||
await monitoring_helper.MonitoringHelper.record_query_success(
|
||||
ap=self.ap,
|
||||
message_id=message_id,
|
||||
@@ -359,6 +442,7 @@ class RuntimePipeline:
|
||||
|
||||
# Record bot response message
|
||||
try:
|
||||
await self._assert_execution_active(query)
|
||||
await monitoring_helper.MonitoringHelper.record_query_response(
|
||||
ap=self.ap,
|
||||
query=query,
|
||||
@@ -371,6 +455,8 @@ class RuntimePipeline:
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record query response: {e}')
|
||||
|
||||
except WorkspaceError as e:
|
||||
self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
|
||||
except Exception as e:
|
||||
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
|
||||
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
|
||||
@@ -380,6 +466,7 @@ class RuntimePipeline:
|
||||
try:
|
||||
from . import monitoring_helper
|
||||
|
||||
await self._assert_execution_active(query)
|
||||
await monitoring_helper.MonitoringHelper.record_query_error(
|
||||
ap=self.ap,
|
||||
query=query,
|
||||
@@ -395,7 +482,7 @@ class RuntimePipeline:
|
||||
|
||||
finally:
|
||||
self.ap.logger.debug(f'Query {query.query_id} processed')
|
||||
del self.ap.query_pool.cached_queries[query.query_id]
|
||||
await self.ap.query_pool.remove_query(query)
|
||||
|
||||
|
||||
class PipelineManager:
|
||||
@@ -425,10 +512,38 @@ class PipelineManager:
|
||||
|
||||
# load pipelines
|
||||
for pipeline in pipelines:
|
||||
await self.load_pipeline(pipeline)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(pipeline.workspace_uuid)
|
||||
await self.load_pipeline(
|
||||
ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
pipeline_uuid=pipeline.uuid,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
),
|
||||
pipeline,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_execution_context(
|
||||
context: ExecutionContext | RequestContext,
|
||||
pipeline_uuid: str,
|
||||
) -> ExecutionContext:
|
||||
if isinstance(context, RequestContext):
|
||||
return ExecutionContext.from_request(context, pipeline_uuid=pipeline_uuid)
|
||||
if not isinstance(context, ExecutionContext):
|
||||
raise WorkspaceRequiredError('Pipeline runtime operations require an ExecutionContext')
|
||||
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
|
||||
raise WorkspaceRequiredError('Pipeline runtime operations require an instance and Workspace')
|
||||
if context.placement_generation <= 0:
|
||||
raise WorkspaceRequiredError('Pipeline runtime operations require a positive placement generation')
|
||||
if context.pipeline_uuid not in (None, pipeline_uuid):
|
||||
raise WorkspaceRequiredError('Pipeline UUID does not match its ExecutionContext')
|
||||
return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)
|
||||
|
||||
async def load_pipeline(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
pipeline_entity: persistence_pipeline.LegacyPipeline
|
||||
| sqlalchemy.Row[persistence_pipeline.LegacyPipeline]
|
||||
| dict,
|
||||
@@ -438,6 +553,14 @@ class PipelineManager:
|
||||
elif isinstance(pipeline_entity, dict):
|
||||
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
||||
|
||||
execution_context = self._normalize_execution_context(context, pipeline_entity.uuid)
|
||||
if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceRequiredError('Pipeline entity Workspace does not match its runtime context')
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
|
||||
coerce_pipeline_config(
|
||||
pipeline_entity.config,
|
||||
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
|
||||
@@ -454,17 +577,36 @@ class PipelineManager:
|
||||
for stage_container in stage_containers:
|
||||
await stage_container.inst.initialize(pipeline_entity.config)
|
||||
|
||||
runtime_pipeline = RuntimePipeline(self.ap, pipeline_entity, stage_containers)
|
||||
runtime_pipeline = RuntimePipeline(
|
||||
self.ap,
|
||||
pipeline_entity,
|
||||
stage_containers,
|
||||
execution_context,
|
||||
)
|
||||
self.pipelines.append(runtime_pipeline)
|
||||
|
||||
async def get_pipeline_by_uuid(self, uuid: str) -> RuntimePipeline | None:
|
||||
async def get_pipeline_by_uuid(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
uuid: str,
|
||||
) -> RuntimePipeline | None:
|
||||
execution_context = self._normalize_execution_context(context, uuid)
|
||||
for pipeline in self.pipelines:
|
||||
if pipeline.pipeline_entity.uuid == uuid:
|
||||
if (
|
||||
pipeline.workspace_uuid == execution_context.workspace_uuid
|
||||
and pipeline.placement_generation == execution_context.placement_generation
|
||||
and pipeline.pipeline_entity.uuid == uuid
|
||||
):
|
||||
return pipeline
|
||||
return None
|
||||
|
||||
async def remove_pipeline(self, uuid: str):
|
||||
async def remove_pipeline(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
uuid: str,
|
||||
) -> None:
|
||||
execution_context = self._normalize_execution_context(context, uuid)
|
||||
for pipeline in self.pipelines:
|
||||
if pipeline.pipeline_entity.uuid == uuid:
|
||||
if pipeline.workspace_uuid == execution_context.workspace_uuid and pipeline.pipeline_entity.uuid == uuid:
|
||||
self.pipelines.remove(pipeline)
|
||||
return
|
||||
|
||||
@@ -1,57 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import inspect
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
|
||||
QueryCacheKey = tuple[str, str]
|
||||
LegacyQueryKey = tuple[str, int]
|
||||
QueryCounterKey = tuple[str, str, int]
|
||||
SingletonContextResolver = typing.Callable[
|
||||
[],
|
||||
ExecutionContext | typing.Awaitable[ExecutionContext],
|
||||
]
|
||||
|
||||
|
||||
class ExecutionContextRequiredError(ValueError):
|
||||
"""Raised when runtime work is created without a trusted Workspace scope."""
|
||||
|
||||
|
||||
class ExecutionContextMismatchError(ValueError):
|
||||
"""Raised when entity fields conflict with their trusted execution scope."""
|
||||
|
||||
|
||||
class QueryNotFoundError(LookupError):
|
||||
"""Raised when a query does not exist inside the requested Workspace."""
|
||||
|
||||
|
||||
def _validate_execution_context(execution_context: ExecutionContext) -> None:
|
||||
if not isinstance(execution_context, ExecutionContext):
|
||||
raise ExecutionContextRequiredError('A trusted ExecutionContext is required')
|
||||
if not isinstance(execution_context.instance_uuid, str) or not execution_context.instance_uuid.strip():
|
||||
raise ExecutionContextRequiredError('ExecutionContext.instance_uuid is required')
|
||||
if not isinstance(execution_context.workspace_uuid, str) or not execution_context.workspace_uuid.strip():
|
||||
raise ExecutionContextRequiredError('ExecutionContext.workspace_uuid is required')
|
||||
if (
|
||||
isinstance(execution_context.placement_generation, bool)
|
||||
or not isinstance(execution_context.placement_generation, int)
|
||||
or execution_context.placement_generation <= 0
|
||||
):
|
||||
raise ExecutionContextRequiredError('ExecutionContext.placement_generation must be a positive integer')
|
||||
for field_name in ('bot_uuid', 'pipeline_uuid', 'query_uuid'):
|
||||
value = getattr(execution_context, field_name)
|
||||
if value is not None and (not isinstance(value, str) or not value.strip()):
|
||||
raise ExecutionContextRequiredError(f'ExecutionContext.{field_name} must be a non-empty string when set')
|
||||
|
||||
|
||||
def bind_execution_context(
|
||||
execution_context: ExecutionContext,
|
||||
*,
|
||||
bot_uuid: str | None = None,
|
||||
pipeline_uuid: str | None = None,
|
||||
query_uuid: str | None = None,
|
||||
) -> ExecutionContext:
|
||||
"""Bind runtime entity identifiers without allowing scope substitution."""
|
||||
|
||||
_validate_execution_context(execution_context)
|
||||
|
||||
requested_fields = {
|
||||
'bot_uuid': bot_uuid,
|
||||
'pipeline_uuid': pipeline_uuid,
|
||||
'query_uuid': query_uuid,
|
||||
}
|
||||
updates: dict[str, str] = {}
|
||||
for field_name, requested_value in requested_fields.items():
|
||||
if requested_value is None:
|
||||
continue
|
||||
if not isinstance(requested_value, str) or not requested_value.strip():
|
||||
raise ExecutionContextRequiredError(f'{field_name} must be a non-empty string')
|
||||
current_value = getattr(execution_context, field_name)
|
||||
if current_value is not None and current_value != requested_value:
|
||||
raise ExecutionContextMismatchError(f'ExecutionContext.{field_name} does not match the runtime entity')
|
||||
if current_value is None:
|
||||
updates[field_name] = requested_value
|
||||
|
||||
if not updates:
|
||||
return execution_context
|
||||
return dataclasses.replace(execution_context, **updates)
|
||||
|
||||
|
||||
def get_query_execution_context(query: pipeline_query.Query) -> ExecutionContext:
|
||||
"""Return and validate the trusted context attached to a Query."""
|
||||
|
||||
attached_context = getattr(query, '_execution_context', None)
|
||||
bot_uuid = getattr(query, 'bot_uuid', None)
|
||||
pipeline_uuid = getattr(query, 'pipeline_uuid', None)
|
||||
query_uuid = getattr(query, 'query_uuid', None)
|
||||
|
||||
if isinstance(attached_context, ExecutionContext):
|
||||
return bind_execution_context(
|
||||
attached_context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
)
|
||||
|
||||
raise ExecutionContextRequiredError('Query is missing its trusted ExecutionContext')
|
||||
|
||||
|
||||
class QueryPool:
|
||||
"""请求池,请求获得调度进入pipeline之前,保存在这里"""
|
||||
|
||||
query_id_counter: int = 0
|
||||
"""Workspace-scoped queue of requests waiting for pipeline scheduling."""
|
||||
|
||||
query_id_counter: int
|
||||
pool_lock: asyncio.Lock
|
||||
|
||||
queries: list[pipeline_query.Query]
|
||||
|
||||
cached_queries: dict[int, pipeline_query.Query]
|
||||
"""Cached queries, used for plugin backward api call, will be removed after the query completely processed"""
|
||||
|
||||
cached_queries: dict[QueryCacheKey, pipeline_query.Query]
|
||||
legacy_query_index: dict[LegacyQueryKey, str]
|
||||
query_count_by_scope: dict[QueryCounterKey, int]
|
||||
condition: asyncio.Condition
|
||||
|
||||
def __init__(self):
|
||||
def __init__(
|
||||
self,
|
||||
singleton_context_resolver: SingletonContextResolver | None = None,
|
||||
):
|
||||
self.query_id_counter = 0
|
||||
self.pool_lock = asyncio.Lock()
|
||||
self.queries = []
|
||||
self.cached_queries = {}
|
||||
self.legacy_query_index = {}
|
||||
self.query_count_by_scope = {}
|
||||
self.condition = asyncio.Condition(self.pool_lock)
|
||||
self._singleton_context_resolver = singleton_context_resolver
|
||||
|
||||
async def resolve_execution_context(
|
||||
self,
|
||||
execution_context: ExecutionContext | None,
|
||||
*,
|
||||
bot_uuid: str,
|
||||
pipeline_uuid: str | None,
|
||||
query_uuid: str | None = None,
|
||||
) -> ExecutionContext:
|
||||
"""Resolve an explicit scope or the opt-in OSS singleton scope."""
|
||||
|
||||
if execution_context is None:
|
||||
if self._singleton_context_resolver is None:
|
||||
raise ExecutionContextRequiredError('ExecutionContext is required; no singleton resolver is configured')
|
||||
resolved_context = self._singleton_context_resolver()
|
||||
if inspect.isawaitable(resolved_context):
|
||||
resolved_context = await resolved_context
|
||||
execution_context = resolved_context
|
||||
|
||||
return bind_execution_context(
|
||||
execution_context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
)
|
||||
|
||||
async def add_query(
|
||||
self,
|
||||
bot_uuid: str,
|
||||
launcher_type: provider_session.LauncherTypes,
|
||||
launcher_id: typing.Union[int, str],
|
||||
sender_id: typing.Union[int, str],
|
||||
launcher_id: int | str,
|
||||
sender_id: int | str,
|
||||
message_event: platform_events.MessageEvent,
|
||||
message_chain: platform_message.MessageChain,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
pipeline_uuid: str | None = None,
|
||||
routed_by_rule: bool = False,
|
||||
variables: typing.Optional[dict[str, typing.Any]] = None,
|
||||
variables: dict[str, typing.Any] | None = None,
|
||||
execution_context: ExecutionContext | None = None,
|
||||
) -> pipeline_query.Query:
|
||||
"""Create a query and cache it under an opaque, Workspace-scoped key."""
|
||||
|
||||
query_uuid = str(uuid.uuid4())
|
||||
execution_context = await self.resolve_execution_context(
|
||||
execution_context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
query_uuid=query_uuid,
|
||||
)
|
||||
|
||||
async with self.condition:
|
||||
query_id = self.query_id_counter
|
||||
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
|
||||
if variables:
|
||||
initial_variables.update(variables)
|
||||
query = pipeline_query.Query(
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
bot_uuid=bot_uuid,
|
||||
query_id=query_id,
|
||||
query_uuid=query_uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=sender_id,
|
||||
@@ -63,12 +202,104 @@ class QueryPool:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
|
||||
# langbot-plugin 0.4.13 ignores these forward-compatible fields.
|
||||
# Attach them explicitly until the Workspace-aware SDK is released.
|
||||
object.__setattr__(query, 'instance_uuid', execution_context.instance_uuid)
|
||||
object.__setattr__(query, 'workspace_uuid', execution_context.workspace_uuid)
|
||||
object.__setattr__(
|
||||
query,
|
||||
'placement_generation',
|
||||
execution_context.placement_generation,
|
||||
)
|
||||
object.__setattr__(query, 'query_uuid', query_uuid)
|
||||
object.__setattr__(query, '_execution_context', execution_context)
|
||||
|
||||
self.queries.append(query)
|
||||
self.cached_queries[query_id] = query
|
||||
self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
|
||||
self.legacy_query_index[(execution_context.workspace_uuid, query_id)] = query_uuid
|
||||
self.query_id_counter += 1
|
||||
counter_key = (
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
execution_context.placement_generation,
|
||||
)
|
||||
self.query_count_by_scope[counter_key] = self.query_count_by_scope.get(counter_key, 0) + 1
|
||||
self.condition.notify_all()
|
||||
return query
|
||||
|
||||
def get_query_count(self, execution_context: ExecutionContext) -> int:
|
||||
"""Return the lifetime query count for one active placement scope."""
|
||||
|
||||
_validate_execution_context(execution_context)
|
||||
return self.query_count_by_scope.get(
|
||||
(
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
execution_context.placement_generation,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
async def get_query(
|
||||
self,
|
||||
workspace_uuid: str,
|
||||
query_uuid: str,
|
||||
) -> pipeline_query.Query | None:
|
||||
"""Return a query only from the explicitly selected Workspace."""
|
||||
|
||||
async with self.pool_lock:
|
||||
return self.cached_queries.get((workspace_uuid, query_uuid))
|
||||
|
||||
async def require_query(
|
||||
self,
|
||||
workspace_uuid: str,
|
||||
query_uuid: str,
|
||||
) -> pipeline_query.Query:
|
||||
"""Return a scoped query or raise without checking other Workspaces."""
|
||||
|
||||
query = await self.get_query(workspace_uuid, query_uuid)
|
||||
if query is None:
|
||||
raise QueryNotFoundError(f'Query {query_uuid!r} was not found in Workspace {workspace_uuid!r}')
|
||||
return query
|
||||
|
||||
async def get_query_by_legacy_id(
|
||||
self,
|
||||
workspace_uuid: str,
|
||||
query_id: int,
|
||||
) -> pipeline_query.Query | None:
|
||||
"""Resolve a legacy integer ID within one explicit Workspace."""
|
||||
|
||||
async with self.pool_lock:
|
||||
query_uuid = self.legacy_query_index.get((workspace_uuid, query_id))
|
||||
if query_uuid is None:
|
||||
return None
|
||||
return self.cached_queries.get((workspace_uuid, query_uuid))
|
||||
|
||||
async def remove_query(self, query: pipeline_query.Query) -> bool:
|
||||
"""Remove a query and both of its Workspace-scoped indexes."""
|
||||
|
||||
execution_context = get_query_execution_context(query)
|
||||
query_uuid = execution_context.query_uuid
|
||||
if query_uuid is None:
|
||||
raise ExecutionContextRequiredError('Query.query_uuid is required for removal')
|
||||
|
||||
async with self.pool_lock:
|
||||
cache_key = (execution_context.workspace_uuid, query_uuid)
|
||||
cached_query = self.cached_queries.get(cache_key)
|
||||
if cached_query is not query:
|
||||
return False
|
||||
del self.cached_queries[cache_key]
|
||||
self.legacy_query_index.pop(
|
||||
(execution_context.workspace_uuid, query.query_id),
|
||||
None,
|
||||
)
|
||||
for index, queued_query in enumerate(self.queries):
|
||||
if queued_query is query:
|
||||
self.queries.pop(index)
|
||||
break
|
||||
return True
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.pool_lock.acquire()
|
||||
return self
|
||||
|
||||
@@ -8,6 +8,7 @@ import langbot_plugin.api.entities.events as events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from ...pipeline.pool import get_query_execution_context
|
||||
|
||||
|
||||
@stage.stage_class('PreProcessor')
|
||||
@@ -70,7 +71,10 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
if primary_uuid:
|
||||
try:
|
||||
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
||||
llm_model = await self.ap.model_mgr.get_model_by_uuid(
|
||||
get_query_execution_context(query),
|
||||
primary_uuid,
|
||||
)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
||||
|
||||
@@ -79,7 +83,10 @@ class PreProcessor(stage.PipelineStage):
|
||||
valid_fallbacks = []
|
||||
for fb_uuid in fallback_uuids:
|
||||
try:
|
||||
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||
await self.ap.model_mgr.get_model_by_uuid(
|
||||
get_query_execution_context(query),
|
||||
fb_uuid,
|
||||
)
|
||||
valid_fallbacks.append(fb_uuid)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||
@@ -131,6 +138,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||
all_tools = await self.ap.tool_mgr.get_all_tools(
|
||||
get_query_execution_context(query),
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
@@ -149,6 +157,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||
all_tools = await self.ap.tool_mgr.get_all_tools(
|
||||
get_query_execution_context(query),
|
||||
bound_plugins,
|
||||
bound_mcp_servers,
|
||||
include_skill_authoring=include_skill_authoring,
|
||||
@@ -279,7 +288,13 @@ 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:
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
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(
|
||||
query.workspace_uuid,
|
||||
query.pipeline_uuid,
|
||||
include_secret=True,
|
||||
)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
|
||||
@@ -291,6 +306,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||
|
||||
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
|
||||
skill_execution_context,
|
||||
bound_skills=bound_skills,
|
||||
)
|
||||
if skill_addition:
|
||||
@@ -319,13 +335,13 @@ class PreProcessor(stage.PipelineStage):
|
||||
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.skills)}'
|
||||
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
|
||||
)
|
||||
else:
|
||||
self.ap.logger.debug(
|
||||
f'No skills available for prompt injection: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.skills)} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))} '
|
||||
f'bound_skills={bound_skills}'
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from ...pool import get_query_execution_context
|
||||
|
||||
|
||||
importutil.import_modules_in_pkg(runners)
|
||||
@@ -198,7 +199,10 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
model_name = None
|
||||
try:
|
||||
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
|
||||
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
m = await self.ap.model_mgr.get_model_by_uuid(
|
||||
get_query_execution_context(query),
|
||||
query.use_llm_model_uuid,
|
||||
)
|
||||
if m and getattr(m, 'model_entity', None):
|
||||
model_name = getattr(m.model_entity, 'name', None)
|
||||
except Exception:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
import sqlalchemy
|
||||
|
||||
from ..core import app, entities as core_entities, taskmgr
|
||||
@@ -14,6 +16,8 @@ from ..entity.persistence import bot as persistence_bot
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
from ..entity.errors import platform as platform_errors
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||||
from ..api.http.authz import WorkspaceRequiredError
|
||||
|
||||
from .logger import EventLogger
|
||||
|
||||
@@ -40,20 +44,50 @@ class RuntimeBot:
|
||||
|
||||
logger: EventLogger
|
||||
|
||||
execution_context: ExecutionContext
|
||||
|
||||
workspace_uuid: str
|
||||
|
||||
placement_generation: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: app.Application,
|
||||
bot_entity: persistence_bot.Bot,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
logger: EventLogger,
|
||||
execution_context: ExecutionContext,
|
||||
):
|
||||
if not isinstance(execution_context, ExecutionContext):
|
||||
raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
|
||||
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
|
||||
raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
|
||||
if execution_context.placement_generation <= 0:
|
||||
raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
|
||||
entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
|
||||
if entity_workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
|
||||
if execution_context.bot_uuid not in (None, bot_entity.uuid):
|
||||
raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')
|
||||
|
||||
self.ap = ap
|
||||
self.bot_entity = bot_entity
|
||||
self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
|
||||
self.workspace_uuid = self.execution_context.workspace_uuid
|
||||
self.placement_generation = self.execution_context.placement_generation
|
||||
self.enable = bot_entity.enable
|
||||
self.adapter = adapter
|
||||
self.task_context = taskmgr.TaskContext()
|
||||
self.logger = logger
|
||||
|
||||
async def assert_execution_active(self) -> None:
|
||||
"""Fail closed when this long-lived adapter belongs to a stale placement."""
|
||||
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
self.workspace_uuid,
|
||||
expected_generation=self.placement_generation,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||
"""Evaluate a single operator condition."""
|
||||
@@ -135,6 +169,28 @@ class RuntimeBot:
|
||||
|
||||
return self.bot_entity.use_pipeline_uuid, False
|
||||
|
||||
def resolve_event_pipeline_uuid(
|
||||
self,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
launcher_type: str,
|
||||
launcher_id: str,
|
||||
message_text: str,
|
||||
message_element_types: list[str] | None = None,
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Resolve a pipeline, honoring a trusted per-task adapter override."""
|
||||
|
||||
get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
|
||||
if callable(get_override):
|
||||
override = get_override()
|
||||
if override:
|
||||
return str(override), False
|
||||
return self.resolve_pipeline_uuid(
|
||||
launcher_type,
|
||||
launcher_id,
|
||||
message_text,
|
||||
message_element_types,
|
||||
)
|
||||
|
||||
async def _record_discarded_message(
|
||||
self,
|
||||
launcher_type: provider_session.LauncherTypes,
|
||||
@@ -162,6 +218,7 @@ class RuntimeBot:
|
||||
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
self.execution_context,
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id=self.PIPELINE_DISCARD,
|
||||
@@ -179,11 +236,13 @@ class RuntimeBot:
|
||||
# Don't overwrite pipeline info — a session may have messages from
|
||||
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||
self.execution_context,
|
||||
session_id,
|
||||
)
|
||||
if not session_updated:
|
||||
# No session yet (first message for this launcher was discarded).
|
||||
await self.ap.monitoring_service.record_session_start(
|
||||
self.execution_context,
|
||||
session_id=session_id,
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
@@ -201,6 +260,7 @@ class RuntimeBot:
|
||||
event: platform_events.FriendMessage,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
):
|
||||
await self.assert_execution_active()
|
||||
image_components = [
|
||||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||||
]
|
||||
@@ -215,7 +275,10 @@ class RuntimeBot:
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
self.execution_context,
|
||||
event,
|
||||
self.bot_entity.uuid,
|
||||
adapter.__class__.__name__,
|
||||
)
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
@@ -229,8 +292,12 @@ class RuntimeBot:
|
||||
|
||||
message_text = str(event.message_chain)
|
||||
element_types = [comp.type for comp in event.message_chain]
|
||||
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||
'person', launcher_id, message_text, element_types
|
||||
pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
|
||||
adapter,
|
||||
'person',
|
||||
launcher_id,
|
||||
message_text,
|
||||
element_types,
|
||||
)
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
@@ -254,6 +321,7 @@ class RuntimeBot:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
execution_context=self.execution_context,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||
@@ -262,6 +330,7 @@ class RuntimeBot:
|
||||
event: platform_events.GroupMessage,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
):
|
||||
await self.assert_execution_active()
|
||||
image_components = [
|
||||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||||
]
|
||||
@@ -276,7 +345,10 @@ class RuntimeBot:
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
self.execution_context,
|
||||
event,
|
||||
self.bot_entity.uuid,
|
||||
adapter.__class__.__name__,
|
||||
)
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
@@ -290,8 +362,12 @@ class RuntimeBot:
|
||||
|
||||
message_text = str(event.message_chain)
|
||||
element_types = [comp.type for comp in event.message_chain]
|
||||
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||
'group', launcher_id, message_text, element_types
|
||||
pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
|
||||
adapter,
|
||||
'group',
|
||||
launcher_id,
|
||||
message_text,
|
||||
element_types,
|
||||
)
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
@@ -315,6 +391,7 @@ class RuntimeBot:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
execution_context=self.execution_context,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||
@@ -328,13 +405,15 @@ class RuntimeBot:
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
):
|
||||
try:
|
||||
await self.assert_execution_active()
|
||||
# Resolve pipeline name
|
||||
pipeline_name = ''
|
||||
if self.bot_entity.use_pipeline_uuid:
|
||||
try:
|
||||
pipeline_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
|
||||
persistence_pipeline.LegacyPipeline.workspace_uuid == self.workspace_uuid,
|
||||
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
)
|
||||
pipeline_row = pipeline_result.first()
|
||||
@@ -344,6 +423,7 @@ class RuntimeBot:
|
||||
pass
|
||||
|
||||
await self.ap.monitoring_service.record_feedback(
|
||||
self.execution_context,
|
||||
feedback_id=event.feedback_id,
|
||||
feedback_type=event.feedback_type,
|
||||
feedback_content=event.feedback_content,
|
||||
@@ -405,7 +485,7 @@ class PlatformManager:
|
||||
|
||||
bots: list[RuntimeBot]
|
||||
|
||||
websocket_proxy_bot: RuntimeBot
|
||||
websocket_proxy_bots: dict[str, RuntimeBot]
|
||||
|
||||
adapter_components: list[engine.Component]
|
||||
|
||||
@@ -414,6 +494,7 @@ class PlatformManager:
|
||||
def __init__(self, ap: app.Application = None):
|
||||
self.ap = ap
|
||||
self.bots = []
|
||||
self.websocket_proxy_bots = {}
|
||||
self.adapter_components = []
|
||||
self.adapter_dict = {}
|
||||
|
||||
@@ -435,19 +516,104 @@ class PlatformManager:
|
||||
if disabled_adapters:
|
||||
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||
|
||||
# initialize websocket adapter
|
||||
websocket_adapter_class = self.adapter_dict['websocket']
|
||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||
websocket_adapter_inst = websocket_adapter_class(
|
||||
{},
|
||||
websocket_logger,
|
||||
ap=self.ap,
|
||||
)
|
||||
await self.load_bots_from_db()
|
||||
|
||||
self.websocket_proxy_bot = RuntimeBot(
|
||||
# OSS may have no persisted bots. Its singleton Workspace still needs
|
||||
# a debug WebSocket proxy. SaaS creates proxies lazily from an explicit
|
||||
# request/runtime context instead of guessing among Workspaces.
|
||||
if not self.websocket_proxy_bots:
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_execution_binding()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await self.get_websocket_proxy_bot(
|
||||
ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def websocket_proxy_bot(self) -> RuntimeBot:
|
||||
"""Compatibility accessor that is safe only for a singleton Workspace."""
|
||||
|
||||
if len(self.websocket_proxy_bots) != 1:
|
||||
raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
|
||||
return next(iter(self.websocket_proxy_bots.values()))
|
||||
|
||||
@websocket_proxy_bot.setter
|
||||
def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
|
||||
"""Keep isolated tests that inject one proxy bot working."""
|
||||
|
||||
workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
|
||||
self.websocket_proxy_bots = {workspace_uuid: runtime_bot}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_execution_context(
|
||||
context: ExecutionContext | RequestContext,
|
||||
*,
|
||||
bot_uuid: str | None = None,
|
||||
pipeline_uuid: str | None = None,
|
||||
) -> ExecutionContext:
|
||||
if isinstance(context, RequestContext):
|
||||
return ExecutionContext.from_request(
|
||||
context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
if not isinstance(context, ExecutionContext):
|
||||
raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
|
||||
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
|
||||
raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
|
||||
if context.placement_generation <= 0:
|
||||
raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
|
||||
updates = {}
|
||||
if bot_uuid is not None:
|
||||
if context.bot_uuid not in (None, bot_uuid):
|
||||
raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
|
||||
updates['bot_uuid'] = bot_uuid
|
||||
if pipeline_uuid is not None:
|
||||
if context.pipeline_uuid not in (None, pipeline_uuid):
|
||||
raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
|
||||
updates['pipeline_uuid'] = pipeline_uuid
|
||||
return dataclasses.replace(context, **updates) if updates else context
|
||||
|
||||
async def get_websocket_proxy_bot(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
) -> RuntimeBot:
|
||||
execution_context = self._normalize_execution_context(context)
|
||||
existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
|
||||
if existing is not None:
|
||||
if existing.placement_generation != execution_context.placement_generation:
|
||||
raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
|
||||
return existing
|
||||
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
websocket_adapter_class = self.adapter_dict['websocket']
|
||||
websocket_logger = EventLogger(
|
||||
name='websocket-adapter',
|
||||
ap=self.ap,
|
||||
execution_context=execution_context,
|
||||
owner='websocket-proxy-bot',
|
||||
)
|
||||
websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
|
||||
proxy_context = dataclasses.replace(
|
||||
execution_context,
|
||||
instance_uuid=binding.instance_uuid,
|
||||
bot_uuid='websocket-proxy-bot',
|
||||
)
|
||||
runtime_bot = RuntimeBot(
|
||||
ap=self.ap,
|
||||
bot_entity=persistence_bot.Bot(
|
||||
uuid='websocket-proxy-bot',
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
name='WebSocket',
|
||||
description='',
|
||||
adapter='websocket',
|
||||
@@ -456,13 +622,20 @@ class PlatformManager:
|
||||
),
|
||||
adapter=websocket_adapter_inst,
|
||||
logger=websocket_logger,
|
||||
execution_context=proxy_context,
|
||||
)
|
||||
await self.websocket_proxy_bot.initialize()
|
||||
await runtime_bot.initialize()
|
||||
self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
|
||||
return runtime_bot
|
||||
|
||||
await self.load_bots_from_db()
|
||||
|
||||
def get_running_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
|
||||
return [bot.adapter for bot in self.bots if bot.enable]
|
||||
def get_running_adapters(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
|
||||
execution_context = self._normalize_execution_context(context)
|
||||
return [
|
||||
bot.adapter for bot in self.bots if bot.enable and bot.workspace_uuid == execution_context.workspace_uuid
|
||||
]
|
||||
|
||||
async def load_bots_from_db(self):
|
||||
self.ap.logger.info('Loading bots from db...')
|
||||
@@ -476,7 +649,15 @@ class PlatformManager:
|
||||
for bot in bots:
|
||||
# load all bots here, enable or disable will be handled in runtime
|
||||
try:
|
||||
await self.load_bot(bot)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(bot.workspace_uuid)
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
bot_uuid=bot.uuid,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
)
|
||||
await self.load_bot(execution_context, bot)
|
||||
except platform_errors.AdapterNotFoundError as e:
|
||||
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
|
||||
except Exception as e:
|
||||
@@ -484,6 +665,7 @@ class PlatformManager:
|
||||
|
||||
async def load_bot(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
|
||||
) -> RuntimeBot:
|
||||
"""加载机器人"""
|
||||
@@ -492,7 +674,20 @@ class PlatformManager:
|
||||
elif isinstance(bot_entity, dict):
|
||||
bot_entity = persistence_bot.Bot(**bot_entity)
|
||||
|
||||
logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)
|
||||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
|
||||
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
|
||||
logger = EventLogger(
|
||||
name=f'platform-adapter-{bot_entity.name}',
|
||||
ap=self.ap,
|
||||
execution_context=execution_context,
|
||||
owner=bot_entity.uuid,
|
||||
)
|
||||
|
||||
if bot_entity.adapter not in self.adapter_dict:
|
||||
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
|
||||
@@ -508,7 +703,13 @@ class PlatformManager:
|
||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||
adapter_inst.set_bot_uuid(bot_entity.uuid)
|
||||
|
||||
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
|
||||
runtime_bot = RuntimeBot(
|
||||
ap=self.ap,
|
||||
bot_entity=bot_entity,
|
||||
adapter=adapter_inst,
|
||||
logger=logger,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
|
||||
await runtime_bot.initialize()
|
||||
|
||||
@@ -516,17 +717,53 @@ class PlatformManager:
|
||||
|
||||
return runtime_bot
|
||||
|
||||
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
||||
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
|
||||
return self.websocket_proxy_bot
|
||||
async def get_bot_by_uuid(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
bot_uuid: str,
|
||||
) -> RuntimeBot | None:
|
||||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
|
||||
proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
|
||||
if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
|
||||
if proxy_bot.placement_generation != execution_context.placement_generation:
|
||||
return None
|
||||
return proxy_bot
|
||||
for bot in self.bots:
|
||||
if bot.bot_entity.uuid == bot_uuid:
|
||||
if (
|
||||
bot.workspace_uuid == execution_context.workspace_uuid
|
||||
and bot.placement_generation == execution_context.placement_generation
|
||||
and bot.bot_entity.uuid == bot_uuid
|
||||
):
|
||||
return bot
|
||||
return None
|
||||
|
||||
async def remove_bot(self, bot_uuid: str):
|
||||
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
|
||||
"""Resolve an opaque public bot UUID without consulting request headers."""
|
||||
|
||||
try:
|
||||
normalized = str(uuid.UUID(route_key))
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return None
|
||||
for bot in self.bots:
|
||||
if bot.bot_entity.uuid == normalized:
|
||||
try:
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
bot.workspace_uuid,
|
||||
expected_generation=bot.placement_generation,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return bot
|
||||
return None
|
||||
|
||||
async def remove_bot(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
bot_uuid: str,
|
||||
) -> None:
|
||||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
|
||||
for bot in self.bots[:]:
|
||||
if bot.bot_entity.uuid == bot_uuid:
|
||||
if bot.workspace_uuid == execution_context.workspace_uuid and bot.bot_entity.uuid == bot_uuid:
|
||||
if bot.enable:
|
||||
await bot.shutdown()
|
||||
self.bots.remove(bot)
|
||||
@@ -551,13 +788,17 @@ class PlatformManager:
|
||||
|
||||
async def run(self):
|
||||
# This method will only be called when the application launching
|
||||
await self.websocket_proxy_bot.run()
|
||||
for proxy_bot in self.websocket_proxy_bots.values():
|
||||
await proxy_bot.run()
|
||||
|
||||
for bot in self.bots:
|
||||
if bot.enable:
|
||||
await bot.run()
|
||||
|
||||
async def shutdown(self):
|
||||
for proxy_bot in self.websocket_proxy_bots.values():
|
||||
if proxy_bot.enable:
|
||||
await proxy_bot.shutdown()
|
||||
for bot in self.bots:
|
||||
if bot.enable:
|
||||
await bot.shutdown()
|
||||
|
||||
@@ -9,6 +9,7 @@ import traceback
|
||||
import uuid
|
||||
|
||||
from ..core import app
|
||||
from ..api.http.context import ExecutionContext
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger
|
||||
|
||||
@@ -65,13 +66,21 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
|
||||
logs: list[EventLog]
|
||||
|
||||
execution_context: ExecutionContext
|
||||
|
||||
owner: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
ap: app.Application,
|
||||
execution_context: ExecutionContext,
|
||||
owner: str,
|
||||
):
|
||||
self.name = name
|
||||
self.ap = ap
|
||||
self.execution_context = execution_context
|
||||
self.owner = owner
|
||||
self.logs = []
|
||||
self.seq_id_inc = 0
|
||||
|
||||
@@ -121,7 +130,11 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
if len(self.logs) > MAX_LOG_COUNT:
|
||||
for i in range(DELETE_COUNT_PER_TIME):
|
||||
for image_key in self.logs[i].images: # type: ignore
|
||||
await self.ap.storage_mgr.storage_provider.delete(image_key)
|
||||
await self.ap.storage_mgr.delete_scoped_object_key(
|
||||
self.execution_context,
|
||||
image_key,
|
||||
expected_owner_type='bot_log',
|
||||
)
|
||||
self.logs = self.logs[DELETE_COUNT_PER_TIME:]
|
||||
|
||||
async def _add_log(
|
||||
@@ -149,8 +162,14 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
extension = mimetypes.guess_extension(mime_type)
|
||||
if extension is None:
|
||||
extension = '.jpg'
|
||||
image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}'
|
||||
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
|
||||
logical_key = f'{message_session_id}-{uuid.uuid4()}{extension}'
|
||||
image_key = await self.ap.storage_mgr.save_scoped(
|
||||
self.execution_context,
|
||||
owner_type='bot_log',
|
||||
owner=self.owner,
|
||||
key=logical_key,
|
||||
value=img_bytes,
|
||||
)
|
||||
image_keys.append(image_key)
|
||||
|
||||
self.logs.append(
|
||||
|
||||
@@ -311,18 +311,40 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool:
|
||||
"""Drop the matching session so the next message starts a fresh conversation."""
|
||||
execution_context = getattr(self.logger, 'execution_context', None)
|
||||
if (
|
||||
execution_context is None
|
||||
or not execution_context.instance_uuid
|
||||
or not execution_context.workspace_uuid
|
||||
or execution_context.placement_generation <= 0
|
||||
or not self.bot_uuid
|
||||
):
|
||||
raise RuntimeError('http_bot reset requires a trusted execution scope')
|
||||
expected_prefix = (
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
execution_context.placement_generation,
|
||||
self.bot_uuid,
|
||||
launcher_type,
|
||||
)
|
||||
|
||||
sess_mgr = self.ap.sess_mgr
|
||||
before = len(sess_mgr.session_list)
|
||||
sess_mgr.session_list = [
|
||||
s
|
||||
for s in sess_mgr.session_list
|
||||
if not (
|
||||
str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type
|
||||
and str(s.launcher_id) == launcher_id
|
||||
)
|
||||
s for s in sess_mgr.session_list if not self._matches_session_scope(s, expected_prefix, launcher_id)
|
||||
]
|
||||
return len(sess_mgr.session_list) < before
|
||||
|
||||
@staticmethod
|
||||
def _matches_session_scope(session, expected_prefix: tuple[str, str, int, str, str], launcher_id: str) -> bool:
|
||||
session_key = getattr(session, '_langbot_session_key', None)
|
||||
return (
|
||||
isinstance(session_key, tuple)
|
||||
and len(session_key) == 6
|
||||
and session_key[:5] == expected_prefix
|
||||
and str(session_key[5]) == launcher_id
|
||||
)
|
||||
|
||||
# -- outbound -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -33,6 +33,8 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
|
||||
@@ -278,8 +280,22 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
|
||||
return
|
||||
try:
|
||||
ap = self.logger.ap
|
||||
execution_context = getattr(self.logger, 'execution_context', None)
|
||||
if not isinstance(execution_context, ExecutionContext):
|
||||
raise RuntimeError('Weixin Bot config persistence requires an ExecutionContext')
|
||||
if execution_context.bot_uuid != self._bot_uuid:
|
||||
raise RuntimeError('Weixin Bot UUID does not match its ExecutionContext')
|
||||
|
||||
binding = await ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise RuntimeError('Weixin Bot Workspace belongs to another LangBot instance')
|
||||
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bot.Bot)
|
||||
.where(persistence_bot.Bot.workspace_uuid == execution_context.workspace_uuid)
|
||||
.where(persistence_bot.Bot.uuid == self._bot_uuid)
|
||||
.values(adapter_config=self.config)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WebSocket适配器 - 支持双向通信的IM系统"""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime
|
||||
@@ -13,9 +14,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ...core import app
|
||||
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
|
||||
from .websocket_manager import WebSocketConnection, WebSocketScope, is_valid_session_id, ws_connection_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
'websocket_pipeline_uuid',
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class WebSocketMessage(pydantic.BaseModel):
|
||||
@@ -113,9 +118,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
return None
|
||||
return pipeline_uuid, session_id
|
||||
|
||||
@classmethod
|
||||
async def _get_connection_from_target(cls, target_id: str):
|
||||
def _scope(self) -> WebSocketScope:
|
||||
"""Return this adapter's immutable runtime placement."""
|
||||
|
||||
return WebSocketScope.from_context(self.logger.execution_context)
|
||||
|
||||
def get_pipeline_uuid_override(self) -> str | None:
|
||||
"""Return the connection pipeline propagated into the listener task."""
|
||||
|
||||
return _current_pipeline_uuid.get()
|
||||
|
||||
async def _get_connection_from_target(self, target_id: str):
|
||||
"""Resolve a person or group WebSocket launcher to its connection."""
|
||||
scope = self._scope()
|
||||
target_value = str(target_id)
|
||||
for prefix in ('websocket_', 'websocketgroup_'):
|
||||
if target_value.startswith(prefix):
|
||||
@@ -123,14 +138,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
connection = await ws_connection_manager.get_connection(target)
|
||||
connection = await ws_connection_manager.get_connection(target, scope=scope)
|
||||
if connection is not None:
|
||||
return connection
|
||||
embed_target = cls._parse_embed_target(target_id)
|
||||
embed_target = self._parse_embed_target(target_id)
|
||||
if embed_target is not None:
|
||||
pipeline_uuid, session_id = embed_target
|
||||
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
|
||||
return await ws_connection_manager.get_connection_by_session_id(target)
|
||||
return await ws_connection_manager.get_connection_by_session_id(
|
||||
session_id,
|
||||
scope=scope,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
return await ws_connection_manager.get_connection_by_session_id(target, scope=scope)
|
||||
|
||||
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
|
||||
"""Resolve the originating pipeline and browser session for a reply."""
|
||||
@@ -142,7 +161,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
embed_target = self._parse_embed_target(sender_id)
|
||||
if embed_target is not None:
|
||||
return embed_target
|
||||
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
|
||||
raise ValueError('WebSocket reply target is not bound to this adapter scope')
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -160,16 +179,17 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
if connection is not None:
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_id = connection.session_id
|
||||
scope = connection.scope
|
||||
else:
|
||||
embed_target = self._parse_embed_target(target_id)
|
||||
if embed_target is not None:
|
||||
pipeline_uuid, session_id = embed_target
|
||||
else:
|
||||
pipeline_uuid = typing.cast(
|
||||
str,
|
||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
pipeline_uuid = str(target_id).strip()
|
||||
if not pipeline_uuid:
|
||||
raise ValueError('WebSocket target pipeline is required')
|
||||
session_id = None
|
||||
scope = self._scope()
|
||||
session_type = 'group' if target_type == 'group' else 'person'
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
|
||||
@@ -195,6 +215,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'session_type': session_type,
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
scope=scope,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -216,6 +237,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
)
|
||||
|
||||
pipeline_uuid, session_id = await self._get_message_context(message_source)
|
||||
scope = self._scope()
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
|
||||
@@ -239,6 +261,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'session_type': session_type,
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
scope=scope,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -262,6 +285,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
)
|
||||
|
||||
pipeline_uuid, session_id = await self._get_message_context(message_source)
|
||||
scope = self._scope()
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
message_list = session.get_message_list(conversation_key)
|
||||
@@ -316,6 +340,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'session_type': session_type,
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
scope=scope,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -360,7 +385,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)
|
||||
# 广播到所有相关连接
|
||||
target_id = message.get('target_id', '')
|
||||
await ws_connection_manager.broadcast_to_pipeline(target_id, message)
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
target_id,
|
||||
message,
|
||||
scope=self._scope(),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
@@ -372,7 +401,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
"""停止适配器"""
|
||||
pass
|
||||
|
||||
async def _process_image_components(self, message_chain_obj: list):
|
||||
async def _process_image_components(
|
||||
self,
|
||||
connection: WebSocketConnection,
|
||||
message_chain_obj: list,
|
||||
):
|
||||
"""
|
||||
处理消息链中的图片、语音和文件组件,将 path 转换为 base64
|
||||
|
||||
@@ -387,14 +420,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
storage_mgr = self.ap.storage_mgr
|
||||
attachments = [
|
||||
component
|
||||
for component in message_chain_obj
|
||||
if component.get('path') and component.get('type') in ('Image', 'Voice', 'File')
|
||||
]
|
||||
if not attachments:
|
||||
return
|
||||
|
||||
for component in message_chain_obj:
|
||||
storage_mgr = self.ap.storage_mgr
|
||||
execution_context = connection.execution_context
|
||||
expected_prefix = storage_mgr.scoped_prefix(execution_context, owner_type='upload_image')
|
||||
|
||||
for component in attachments:
|
||||
comp_type = component.get('type', '')
|
||||
comp_path = component.get('path', '')
|
||||
|
||||
if not comp_path or comp_type not in ('Image', 'Voice', 'File'):
|
||||
continue
|
||||
if not comp_path.startswith(expected_prefix) or not storage_mgr.is_scoped_object_key(
|
||||
comp_path,
|
||||
expected_owner_type='upload_image',
|
||||
):
|
||||
await self.logger.warning(f'Rejected {comp_type} attachment outside the WebSocket connection scope')
|
||||
raise ValueError('Attachment key does not belong to this WebSocket connection')
|
||||
|
||||
try:
|
||||
file_content = await storage_mgr.storage_provider.load(comp_path)
|
||||
@@ -416,10 +463,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
|
||||
|
||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||
await storage_mgr.storage_provider.delete(comp_path)
|
||||
await storage_mgr.delete_scoped_object_key(
|
||||
execution_context,
|
||||
comp_path,
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
component['path'] = ''
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
|
||||
raise
|
||||
|
||||
async def handle_websocket_message(
|
||||
self,
|
||||
@@ -451,7 +503,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
message_chain_obj = message_data.get('message', [])
|
||||
|
||||
await self._process_image_components(message_chain_obj)
|
||||
await self._process_image_components(connection, message_chain_obj)
|
||||
|
||||
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
|
||||
|
||||
@@ -476,6 +528,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'session_type': session_type,
|
||||
'data': user_message.model_dump(),
|
||||
},
|
||||
scope=connection.scope,
|
||||
session_type=session_type,
|
||||
session_id=connection.session_id,
|
||||
)
|
||||
@@ -506,11 +559,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
|
||||
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
|
||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
if owner_bot is not None:
|
||||
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
|
||||
# 异步触发事件处理
|
||||
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
|
||||
listeners = (
|
||||
@@ -525,7 +573,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
owner_bot.adapter.set_ws_adapter(self)
|
||||
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
|
||||
if event.__class__ in listeners:
|
||||
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||
token = _current_pipeline_uuid.set(pipeline_uuid)
|
||||
try:
|
||||
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||
finally:
|
||||
_current_pipeline_uuid.reset(token)
|
||||
|
||||
def get_websocket_messages(
|
||||
self,
|
||||
@@ -558,11 +610,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
if session_type == 'group'
|
||||
else f'websocket_{pipeline_uuid}:{session_id}'
|
||||
)
|
||||
scope = self._scope()
|
||||
self.ap.sess_mgr.session_list = [
|
||||
candidate_session
|
||||
for candidate_session in self.ap.sess_mgr.session_list
|
||||
if not (
|
||||
str(
|
||||
getattr(candidate_session, 'instance_uuid', None) == scope.instance_uuid
|
||||
and getattr(candidate_session, 'workspace_uuid', None) == scope.workspace_uuid
|
||||
and getattr(candidate_session, 'placement_generation', None) == scope.placement_generation
|
||||
and str(
|
||||
candidate_session.launcher_type.value
|
||||
if hasattr(candidate_session.launcher_type, 'value')
|
||||
else candidate_session.launcher_type
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WebSocket连接管理器 - 管理多个并发WebSocket连接"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import typing
|
||||
import uuid
|
||||
@@ -8,10 +9,35 @@ from datetime import datetime
|
||||
|
||||
import pydantic
|
||||
|
||||
from ...api.http.context import ExecutionContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SESSION_FILTER_UNSET = object()
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class WebSocketScope:
|
||||
"""Trusted runtime placement carried by every WebSocket connection."""
|
||||
|
||||
instance_uuid: str
|
||||
workspace_uuid: str
|
||||
placement_generation: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.instance_uuid.strip() or not self.workspace_uuid.strip():
|
||||
raise ValueError('WebSocket scope requires an instance and Workspace')
|
||||
if self.placement_generation <= 0:
|
||||
raise ValueError('WebSocket scope requires a positive placement generation')
|
||||
|
||||
@classmethod
|
||||
def from_context(cls, context: typing.Any) -> 'WebSocketScope':
|
||||
return cls(
|
||||
instance_uuid=str(getattr(context, 'instance_uuid', '')),
|
||||
workspace_uuid=str(getattr(context, 'workspace_uuid', '')),
|
||||
placement_generation=int(getattr(context, 'placement_generation', 0)),
|
||||
)
|
||||
|
||||
|
||||
def is_valid_session_id(value: str) -> bool:
|
||||
"""Accept only canonical random UUIDs for client conversation identifiers."""
|
||||
try:
|
||||
@@ -29,6 +55,15 @@ class WebSocketConnection(pydantic.BaseModel):
|
||||
connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""连接唯一ID"""
|
||||
|
||||
instance_uuid: str
|
||||
"""Owning LangBot instance."""
|
||||
|
||||
workspace_uuid: str
|
||||
"""Owning Workspace."""
|
||||
|
||||
placement_generation: int
|
||||
"""Workspace placement generation captured at connect time."""
|
||||
|
||||
pipeline_uuid: str
|
||||
"""关联的流水线UUID"""
|
||||
|
||||
@@ -56,6 +91,25 @@ class WebSocketConnection(pydantic.BaseModel):
|
||||
metadata: dict = pydantic.Field(default_factory=dict)
|
||||
"""连接元数据(可存储额外信息)"""
|
||||
|
||||
@property
|
||||
def scope(self) -> WebSocketScope:
|
||||
return WebSocketScope(
|
||||
instance_uuid=self.instance_uuid,
|
||||
workspace_uuid=self.workspace_uuid,
|
||||
placement_generation=self.placement_generation,
|
||||
)
|
||||
|
||||
@property
|
||||
def execution_context(self) -> ExecutionContext:
|
||||
"""Return the storage/runtime context captured for this connection."""
|
||||
|
||||
return ExecutionContext(
|
||||
instance_uuid=self.instance_uuid,
|
||||
workspace_uuid=self.workspace_uuid,
|
||||
placement_generation=self.placement_generation,
|
||||
pipeline_uuid=self.pipeline_uuid,
|
||||
)
|
||||
|
||||
|
||||
class WebSocketConnectionManager:
|
||||
"""WebSocket连接管理器 - 支持多连接并发"""
|
||||
@@ -64,11 +118,11 @@ class WebSocketConnectionManager:
|
||||
self.connections: dict[str, WebSocketConnection] = {}
|
||||
"""所有活跃连接 {connection_id: connection}"""
|
||||
|
||||
self.pipeline_connections: dict[str, set[str]] = {}
|
||||
"""流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}"""
|
||||
self.pipeline_connections: dict[tuple[str, str, int, str], set[str]] = {}
|
||||
"""Scoped pipeline to connection mapping."""
|
||||
|
||||
self.session_connections: dict[str, set[str]] = {}
|
||||
"""会话类型到连接的映射 {session_type: {connection_id, ...}}"""
|
||||
self.session_connections: dict[tuple[str, str, int, str], set[str]] = {}
|
||||
"""Scoped session-type to connection mapping."""
|
||||
|
||||
self._lock = asyncio.Lock()
|
||||
"""线程锁,保护并发访问"""
|
||||
@@ -76,6 +130,7 @@ class WebSocketConnectionManager:
|
||||
async def add_connection(
|
||||
self,
|
||||
websocket: typing.Any,
|
||||
scope: WebSocketScope,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
metadata: dict | None = None,
|
||||
@@ -84,6 +139,9 @@ class WebSocketConnectionManager:
|
||||
"""Register a WebSocket connection and its optional embed session."""
|
||||
async with self._lock:
|
||||
connection = WebSocketConnection(
|
||||
instance_uuid=scope.instance_uuid,
|
||||
workspace_uuid=scope.workspace_uuid,
|
||||
placement_generation=scope.placement_generation,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
@@ -94,18 +152,21 @@ class WebSocketConnectionManager:
|
||||
self.connections[connection.connection_id] = connection
|
||||
|
||||
# 更新流水线映射
|
||||
if pipeline_uuid not in self.pipeline_connections:
|
||||
self.pipeline_connections[pipeline_uuid] = set()
|
||||
self.pipeline_connections[pipeline_uuid].add(connection.connection_id)
|
||||
pipeline_key = self._pipeline_key(scope, pipeline_uuid)
|
||||
if pipeline_key not in self.pipeline_connections:
|
||||
self.pipeline_connections[pipeline_key] = set()
|
||||
self.pipeline_connections[pipeline_key].add(connection.connection_id)
|
||||
|
||||
# 更新会话类型映射
|
||||
if session_type not in self.session_connections:
|
||||
self.session_connections[session_type] = set()
|
||||
self.session_connections[session_type].add(connection.connection_id)
|
||||
session_key = self._session_key(scope, session_type)
|
||||
if session_key not in self.session_connections:
|
||||
self.session_connections[session_key] = set()
|
||||
self.session_connections[session_key].add(connection.connection_id)
|
||||
|
||||
logger.debug(
|
||||
f'WebSocket connection established: {connection.connection_id} '
|
||||
f'(pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
f'(workspace={scope.workspace_uuid}, generation={scope.placement_generation}, '
|
||||
f'pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
)
|
||||
|
||||
return connection
|
||||
@@ -120,28 +181,59 @@ class WebSocketConnectionManager:
|
||||
connection.is_active = False
|
||||
|
||||
# 从流水线映射中移除
|
||||
if connection.pipeline_uuid in self.pipeline_connections:
|
||||
self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)
|
||||
if not self.pipeline_connections[connection.pipeline_uuid]:
|
||||
del self.pipeline_connections[connection.pipeline_uuid]
|
||||
pipeline_key = self._pipeline_key(connection.scope, connection.pipeline_uuid)
|
||||
if pipeline_key in self.pipeline_connections:
|
||||
self.pipeline_connections[pipeline_key].discard(connection_id)
|
||||
if not self.pipeline_connections[pipeline_key]:
|
||||
del self.pipeline_connections[pipeline_key]
|
||||
|
||||
# 从会话类型映射中移除
|
||||
if connection.session_type in self.session_connections:
|
||||
self.session_connections[connection.session_type].discard(connection_id)
|
||||
if not self.session_connections[connection.session_type]:
|
||||
del self.session_connections[connection.session_type]
|
||||
session_key = self._session_key(connection.scope, connection.session_type)
|
||||
if session_key in self.session_connections:
|
||||
self.session_connections[session_key].discard(connection_id)
|
||||
if not self.session_connections[session_key]:
|
||||
del self.session_connections[session_key]
|
||||
|
||||
del self.connections[connection_id]
|
||||
|
||||
logger.debug(f'WebSocket connection disconnected: {connection_id}')
|
||||
|
||||
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
|
||||
"""Get a connection by its transport identifier."""
|
||||
return self.connections.get(connection_id)
|
||||
@staticmethod
|
||||
def _pipeline_key(scope: WebSocketScope, pipeline_uuid: str) -> tuple[str, str, int, str]:
|
||||
return (
|
||||
scope.instance_uuid,
|
||||
scope.workspace_uuid,
|
||||
scope.placement_generation,
|
||||
pipeline_uuid,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _session_key(scope: WebSocketScope, session_type: str) -> tuple[str, str, int, str]:
|
||||
return (
|
||||
scope.instance_uuid,
|
||||
scope.workspace_uuid,
|
||||
scope.placement_generation,
|
||||
session_type,
|
||||
)
|
||||
|
||||
async def get_connection(
|
||||
self,
|
||||
connection_id: str,
|
||||
*,
|
||||
scope: WebSocketScope,
|
||||
) -> WebSocketConnection | None:
|
||||
"""Get a connection only when it belongs to the expected placement."""
|
||||
|
||||
connection = self.connections.get(connection_id)
|
||||
if connection is None or connection.scope != scope:
|
||||
return None
|
||||
return connection
|
||||
|
||||
async def get_connection_by_session_id(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
scope: WebSocketScope,
|
||||
pipeline_uuid: str | None = None,
|
||||
) -> WebSocketConnection | None:
|
||||
"""Get an active embed connection by its stable browser session identifier."""
|
||||
@@ -149,25 +241,38 @@ class WebSocketConnectionManager:
|
||||
if (
|
||||
connection.session_id == session_id
|
||||
and connection.is_active
|
||||
and connection.scope == scope
|
||||
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
|
||||
):
|
||||
return connection
|
||||
return None
|
||||
|
||||
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
|
||||
async def get_connections_by_pipeline(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
*,
|
||||
scope: WebSocketScope,
|
||||
) -> list[WebSocketConnection]:
|
||||
"""获取指定流水线的所有连接"""
|
||||
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
|
||||
connection_ids = self.pipeline_connections.get(self._pipeline_key(scope, pipeline_uuid), set())
|
||||
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||
|
||||
async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:
|
||||
async def get_connections_by_session_type(
|
||||
self,
|
||||
session_type: str,
|
||||
*,
|
||||
scope: WebSocketScope,
|
||||
) -> list[WebSocketConnection]:
|
||||
"""获取指定会话类型的所有连接"""
|
||||
connection_ids = self.session_connections.get(session_type, set())
|
||||
connection_ids = self.session_connections.get(self._session_key(scope, session_type), set())
|
||||
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||
|
||||
async def broadcast_to_pipeline(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
message: dict,
|
||||
*,
|
||||
scope: WebSocketScope,
|
||||
session_type: str | None = None,
|
||||
session_id: typing.Any = _SESSION_FILTER_UNSET,
|
||||
):
|
||||
@@ -180,7 +285,7 @@ class WebSocketConnectionManager:
|
||||
session_id: Embed conversation filter. Omit it to broadcast across
|
||||
conversations; pass ``None`` to target non-embed connections.
|
||||
"""
|
||||
connections = await self.get_connections_by_pipeline(pipeline_uuid)
|
||||
connections = await self.get_connections_by_pipeline(pipeline_uuid, scope=scope)
|
||||
|
||||
if session_type is not None:
|
||||
connections = [conn for conn in connections if conn.session_type == session_type]
|
||||
@@ -196,7 +301,7 @@ class WebSocketConnectionManager:
|
||||
|
||||
async def send_to_connection(self, connection_id: str, message: dict):
|
||||
"""向指定连接发送消息"""
|
||||
connection = await self.get_connection(connection_id)
|
||||
connection = self.connections.get(connection_id)
|
||||
if not connection or not connection.is_active:
|
||||
logger.warning(f'Attempt to send message to invalid connection: {connection_id}')
|
||||
return
|
||||
@@ -210,17 +315,24 @@ class WebSocketConnectionManager:
|
||||
|
||||
async def update_activity(self, connection_id: str):
|
||||
"""更新连接活跃时间"""
|
||||
connection = await self.get_connection(connection_id)
|
||||
connection = self.connections.get(connection_id)
|
||||
if connection:
|
||||
connection.last_active = datetime.now()
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""获取连接统计信息"""
|
||||
def get_stats(self, *, scope: WebSocketScope) -> dict:
|
||||
"""Return connection statistics for one trusted placement."""
|
||||
|
||||
scoped_connections = [connection for connection in self.connections.values() if connection.scope == scope]
|
||||
pipelines: dict[str, int] = {}
|
||||
session_types: dict[str, int] = {}
|
||||
for connection in scoped_connections:
|
||||
pipelines[connection.pipeline_uuid] = pipelines.get(connection.pipeline_uuid, 0) + 1
|
||||
session_types[connection.session_type] = session_types.get(connection.session_type, 0) + 1
|
||||
return {
|
||||
'total_connections': len(self.connections),
|
||||
'pipelines': len(self.pipeline_connections),
|
||||
'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},
|
||||
'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},
|
||||
'total_connections': len(scoped_connections),
|
||||
'pipelines': len(pipelines),
|
||||
'connections_by_pipeline': pipelines,
|
||||
'connections_by_session_type': session_types,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import aiohttp
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -24,14 +25,20 @@ class WebhookPusher:
|
||||
self.ap = ap
|
||||
self.logger = self.ap.logger
|
||||
|
||||
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> bool:
|
||||
async def push_person_message(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
event: platform_events.FriendMessage,
|
||||
bot_uuid: str,
|
||||
adapter_name: str,
|
||||
) -> bool:
|
||||
"""Push person message event to webhooks
|
||||
|
||||
Returns:
|
||||
bool: True if any webhook responded with skip_pipeline=true, False otherwise
|
||||
"""
|
||||
try:
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks(execution_context)
|
||||
if not webhooks:
|
||||
return False
|
||||
|
||||
@@ -67,14 +74,20 @@ class WebhookPusher:
|
||||
self.logger.error(f'Failed to push person message to webhooks: {e}')
|
||||
return False
|
||||
|
||||
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> bool:
|
||||
async def push_group_message(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
event: platform_events.GroupMessage,
|
||||
bot_uuid: str,
|
||||
adapter_name: str,
|
||||
) -> bool:
|
||||
"""Push group message event to webhooks
|
||||
|
||||
Returns:
|
||||
bool: True if any webhook responded with skip_pipeline=true, False otherwise
|
||||
"""
|
||||
try:
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks(execution_context)
|
||||
if not webhooks:
|
||||
return False
|
||||
|
||||
|
||||
+236
-166
@@ -9,6 +9,7 @@ import zipfile
|
||||
from typing import Any
|
||||
import typing
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import httpx
|
||||
import sqlalchemy
|
||||
@@ -33,8 +34,17 @@ from langbot_plugin.api.entities.builtin.command import (
|
||||
errors as command_errors,
|
||||
)
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
from langbot_plugin.runtime.security import (
|
||||
PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
|
||||
PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
|
||||
validate_runtime_secret,
|
||||
)
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from ..core import taskmgr
|
||||
from ..entity.persistence import plugin as persistence_plugin
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ..workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
_CONNECT_TIMEOUT_SEC = 30.0
|
||||
@@ -73,28 +83,68 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
runtime_disconnect_callback: typing.Callable[
|
||||
[PluginRuntimeConnector], typing.Coroutine[typing.Any, typing.Any, None]
|
||||
],
|
||||
action_context: ActionContext | None = None,
|
||||
):
|
||||
super().__init__(ap)
|
||||
self.runtime_disconnect_callback = runtime_disconnect_callback
|
||||
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
|
||||
self._transport_task: asyncio.Task | None = None
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._generation = 0
|
||||
self._connected = asyncio.Event()
|
||||
self._configured_action_context = (
|
||||
ActionContext.model_validate(action_context).without_installation() if action_context is not None else None
|
||||
)
|
||||
self._control_token = str(os.environ.get(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV) or '').strip()
|
||||
|
||||
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
|
||||
runtime_handler = getattr(self, 'handler', None)
|
||||
if runtime_handler is None:
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
||||
return runtime_handler
|
||||
def _requires_explicit_workspace_binding(self) -> bool:
|
||||
"""Return whether this process may host more than the OSS singleton."""
|
||||
|
||||
def _runtime_available(self) -> bool:
|
||||
runtime_handler = getattr(self, 'handler', None)
|
||||
if runtime_handler is None:
|
||||
return False
|
||||
# Unit-level and explicitly injected handlers don't own a transport.
|
||||
# A managed transport must also have completed its handshake.
|
||||
return self._transport_task is None or self._connected.is_set()
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
policy = getattr(workspace_service, 'policy', None)
|
||||
return getattr(policy, 'multi_workspace_enabled', False) is True
|
||||
|
||||
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
|
||||
if not self._control_token and allow_generate:
|
||||
self._control_token = secrets.token_urlsafe(48)
|
||||
try:
|
||||
self._control_token = validate_runtime_secret(
|
||||
self._control_token,
|
||||
name=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise PluginRuntimeNotConnectedError(
|
||||
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
|
||||
'for an external Plugin Runtime'
|
||||
) from exc
|
||||
return {PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER: self._control_token}
|
||||
|
||||
async def _resolve_action_context(self) -> ActionContext:
|
||||
"""Resolve the trusted connector binding; never use plugin input."""
|
||||
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
if workspace_service is None:
|
||||
raise RuntimeError('Plugin Runtime Workspace binding is unavailable')
|
||||
|
||||
if self._configured_action_context is not None:
|
||||
configured = self._configured_action_context
|
||||
binding = await workspace_service.get_execution_binding(
|
||||
configured.workspace_uuid,
|
||||
expected_generation=configured.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != configured.instance_uuid:
|
||||
raise RuntimeError('Plugin Runtime Workspace binding belongs to another instance')
|
||||
return ActionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
|
||||
if self._requires_explicit_workspace_binding():
|
||||
raise RuntimeError('Cloud plugin Runtime connectors require an explicit projected Workspace binding')
|
||||
|
||||
binding = await workspace_service.get_local_execution_binding()
|
||||
return ActionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
|
||||
async def heartbeat_loop(self):
|
||||
failures = 0
|
||||
@@ -118,6 +168,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if not self.is_enable_plugin:
|
||||
self.ap.logger.info('Plugin system is disabled.')
|
||||
return
|
||||
if self._configured_action_context is None and self._requires_explicit_workspace_binding():
|
||||
raise RuntimeError('Cloud plugin Runtime connectors require an explicit projected Workspace binding')
|
||||
|
||||
async with self._lifecycle_lock:
|
||||
if self._closing:
|
||||
@@ -158,31 +210,29 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
await notify_disconnect()
|
||||
return False
|
||||
|
||||
runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
|
||||
self.handler = runtime_handler
|
||||
self.handler_task = asyncio.create_task(runtime_handler.run())
|
||||
try:
|
||||
await runtime_handler.ping()
|
||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
||||
if space_url:
|
||||
await runtime_handler.set_runtime_config(cloud_service_url=space_url)
|
||||
if generation == self._generation and not self._closing:
|
||||
connection_ready = True
|
||||
self._connected.set()
|
||||
self.ap.logger.info('Connected to plugin runtime.')
|
||||
await self.handler_task
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not self._connected.is_set():
|
||||
connect_errors.append(exc)
|
||||
self._connected.set()
|
||||
finally:
|
||||
if generation == self._generation and not self._closing:
|
||||
self._connected.clear()
|
||||
if getattr(self, 'handler', None) is runtime_handler:
|
||||
del self.handler
|
||||
await notify_disconnect()
|
||||
action_context = await self._resolve_action_context()
|
||||
self.handler = handler.RuntimeConnectionHandler(
|
||||
connection,
|
||||
disconnect_callback,
|
||||
self.ap,
|
||||
action_context,
|
||||
)
|
||||
|
||||
self.handler_task = asyncio.create_task(self.handler.run())
|
||||
_ = await self.handler.ping()
|
||||
# Push the configured marketplace (Space) URL to the runtime so it
|
||||
# downloads plugins from the same Space LangBot is bound to, rather
|
||||
# than relying on the runtime's own env/default.
|
||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
||||
try:
|
||||
await self.handler.set_runtime_config(cloud_service_url=space_url or None)
|
||||
if space_url:
|
||||
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to bind plugin runtime config: {e}')
|
||||
raise
|
||||
self.ap.logger.info('Connected to plugin runtime.')
|
||||
await self.handler_task
|
||||
|
||||
task_coro: typing.Coroutine
|
||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
|
||||
@@ -191,86 +241,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
'ws://langbot_plugin_runtime:5400/control/ws',
|
||||
)
|
||||
|
||||
async def connection_failed(ctrl, exc=None):
|
||||
error = exc or RuntimeError('WebSocket connection failed')
|
||||
connect_errors.append(error)
|
||||
self._connected.set()
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=connection_failed,
|
||||
)
|
||||
task_coro = self.ctrl.run(new_connection_callback)
|
||||
elif platform.get_platform() == 'win32':
|
||||
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
|
||||
ws_url = 'ws://localhost:5400/control/ws'
|
||||
|
||||
async def connection_failed(ctrl, exc=None):
|
||||
error = exc or RuntimeError('WebSocket connection failed')
|
||||
connect_errors.append(error)
|
||||
self._connected.set()
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=connection_failed,
|
||||
)
|
||||
task_coro = self.ctrl.run(new_connection_callback)
|
||||
else:
|
||||
self.ctrl = stdio_client_controller.StdioClientController(
|
||||
command=sys.executable,
|
||||
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
|
||||
env=os.environ.copy(),
|
||||
capture_stderr=False,
|
||||
)
|
||||
task_coro = self.ctrl.run(new_connection_callback)
|
||||
|
||||
self._transport_task = asyncio.create_task(task_coro)
|
||||
try:
|
||||
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
|
||||
except asyncio.TimeoutError as exc:
|
||||
await self._stop_transport()
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
|
||||
if connect_errors:
|
||||
await self._stop_transport()
|
||||
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
|
||||
|
||||
if self.heartbeat_task is None or self.heartbeat_task.done():
|
||||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||||
|
||||
def schedule_reconnect(self) -> None:
|
||||
if self._closing or not self.is_enable_plugin:
|
||||
return
|
||||
if self._reconnect_task is not None and not self._reconnect_task.done():
|
||||
return
|
||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||
|
||||
async def _reconnect_loop(self) -> None:
|
||||
delay = 1.0
|
||||
try:
|
||||
while not self._closing:
|
||||
try:
|
||||
await self.initialize()
|
||||
return
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Plugin runtime reconnection failed: {exc}; retrying in {delay:.0f}s')
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, _RECONNECT_MAX_DELAY_SEC)
|
||||
finally:
|
||||
self._reconnect_task = None
|
||||
|
||||
async def _stop_transport(self) -> None:
|
||||
self._connected.clear()
|
||||
runtime_handler = getattr(self, 'handler', None)
|
||||
if runtime_handler is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await runtime_handler.close()
|
||||
if getattr(self, 'handler', None) is runtime_handler:
|
||||
del self.handler
|
||||
tasks = [
|
||||
task
|
||||
for task in (
|
||||
getattr(self, 'handler_task', None),
|
||||
self._transport_task,
|
||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): # use websocket
|
||||
self.ap.logger.info('use websocket to connect to plugin runtime')
|
||||
control_headers = self._control_headers(allow_generate=False)
|
||||
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
|
||||
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
)
|
||||
if task is not None and task is not asyncio.current_task()
|
||||
]
|
||||
@@ -286,20 +261,73 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
with contextlib.suppress(Exception):
|
||||
await close_ctrl()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self._closing = True
|
||||
self._generation += 1
|
||||
reconnect_task = self._reconnect_task
|
||||
self._reconnect_task = None
|
||||
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
|
||||
reconnect_task.cancel()
|
||||
await asyncio.gather(reconnect_task, return_exceptions=True)
|
||||
if self.heartbeat_task is not None:
|
||||
self.heartbeat_task.cancel()
|
||||
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
|
||||
self.heartbeat_task = None
|
||||
await self._stop_transport()
|
||||
await self._close_managed_subprocess()
|
||||
async def make_connection_failed_callback(
|
||||
ctrl: ws_client_controller.WebSocketClientController,
|
||||
exc: Exception = None,
|
||||
) -> None:
|
||||
if exc is not None:
|
||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
|
||||
else:
|
||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=make_connection_failed_callback,
|
||||
additional_headers=control_headers,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
elif platform.get_platform() == 'win32':
|
||||
# Due to Windows's lack of supports for both stdio and subprocess:
|
||||
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
|
||||
# We have to launch runtime via cmd but communicate via ws.
|
||||
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
|
||||
|
||||
control_headers = self._control_headers(allow_generate=True)
|
||||
await self._start_runtime_subprocess(
|
||||
'-m',
|
||||
'langbot_plugin.cli.__init__',
|
||||
'rt',
|
||||
env_overrides={PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: self._control_token},
|
||||
)
|
||||
|
||||
ws_url = 'ws://localhost:5400/control/ws'
|
||||
|
||||
async def make_connection_failed_callback(
|
||||
ctrl: ws_client_controller.WebSocketClientController,
|
||||
exc: Exception = None,
|
||||
) -> None:
|
||||
if exc is not None:
|
||||
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
|
||||
else:
|
||||
self.ap.logger.error(
|
||||
f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'
|
||||
)
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=make_connection_failed_callback,
|
||||
additional_headers=control_headers,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
|
||||
else: # stdio
|
||||
self.ap.logger.info('use stdio to connect to plugin runtime')
|
||||
# cmd: lbp rt -s
|
||||
python_path = sys.executable
|
||||
env = os.environ.copy()
|
||||
self.ctrl = stdio_client_controller.StdioClientController(
|
||||
command=python_path,
|
||||
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
|
||||
env=env,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
|
||||
if self.heartbeat_task is None:
|
||||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||||
|
||||
asyncio.create_task(task)
|
||||
|
||||
async def initialize_plugins(self):
|
||||
pass
|
||||
@@ -307,6 +335,48 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
async def ping_plugin_runtime(self):
|
||||
return await self._runtime_handler().ping()
|
||||
|
||||
async def require_workspace_context(self, context: TenantContext) -> ExecutionContext:
|
||||
"""Fence an HTTP/runtime caller to this connector's one Workspace.
|
||||
|
||||
A Plugin Runtime connection is deliberately not a cross-Workspace
|
||||
router. Calls from another Workspace therefore look like an absent
|
||||
resource instead of being forwarded to the bound Runtime.
|
||||
"""
|
||||
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
||||
raise WorkspaceNotFoundError('Plugin resource not found')
|
||||
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
workspace_uuid,
|
||||
expected_generation=generation,
|
||||
)
|
||||
if binding.instance_uuid != instance_uuid:
|
||||
raise WorkspaceNotFoundError('Plugin resource not found')
|
||||
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
trigger_principal=getattr(context, 'principal', None),
|
||||
)
|
||||
if not self.is_enable_plugin:
|
||||
return execution_context
|
||||
if not hasattr(self, 'handler'):
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
||||
|
||||
bound_context = self.handler.require_bound_action_context().without_installation()
|
||||
if (
|
||||
bound_context.instance_uuid != instance_uuid
|
||||
or bound_context.workspace_uuid != workspace_uuid
|
||||
or bound_context.placement_generation != generation
|
||||
):
|
||||
raise WorkspaceNotFoundError('Plugin resource not found')
|
||||
|
||||
return execution_context
|
||||
|
||||
def _inspect_plugin_package(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
@@ -345,6 +415,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
async def _install_mcp_from_marketplace(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
mcp_data: dict[str, Any],
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
):
|
||||
@@ -357,9 +428,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
|
||||
``ssereadtimeout``.
|
||||
"""
|
||||
from ..entity.persistence import mcp as persistence_mcp
|
||||
import uuid
|
||||
|
||||
mode = mcp_data.get('mode') or 'stdio'
|
||||
extra_args = mcp_data.get('extra_args') or {}
|
||||
# The MCP transport selection was simplified to two modes: 'stdio'
|
||||
@@ -377,18 +445,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
# Use __ instead of / to avoid URL routing issues with slashes
|
||||
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
|
||||
|
||||
# Check if MCP server already exists
|
||||
existing = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
existing = await self.ap.mcp_service.get_mcp_server_by_name(execution_context, name)
|
||||
if existing is not None:
|
||||
self.ap.logger.info(f'MCP server {name} already exists, skipping installation')
|
||||
return
|
||||
|
||||
# Create MCP server record
|
||||
server_uuid = str(uuid.uuid4())
|
||||
server_data = {
|
||||
'uuid': server_uuid,
|
||||
'name': name,
|
||||
'enable': True,
|
||||
'mode': mode,
|
||||
@@ -396,23 +458,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
'readme': readme,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
||||
|
||||
# Start the MCP server
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
|
||||
)
|
||||
server_entity = result.first()
|
||||
if server_entity:
|
||||
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
|
||||
if self.ap.tool_mgr.mcp_tool_loader:
|
||||
mcp_task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
|
||||
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(mcp_task)
|
||||
await self.ap.mcp_service.create_mcp_server(execution_context, server_data)
|
||||
|
||||
self.ap.logger.info(f'Installed MCP server {name} from marketplace')
|
||||
|
||||
async def _install_skill_from_zip(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
@@ -426,6 +478,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
# Install from ZIP using skill service
|
||||
result = await skill_service.install_from_zip_upload(
|
||||
execution_context,
|
||||
file_bytes=file_bytes,
|
||||
filename=filename + '.zip',
|
||||
)
|
||||
@@ -494,6 +547,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_name = install_info.get('plugin_name')
|
||||
|
||||
if install_source == PluginInstallSource.MARKETPLACE:
|
||||
action_context = self.handler.require_bound_action_context()
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=action_context.instance_uuid,
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
placement_generation=action_context.placement_generation,
|
||||
)
|
||||
# Handle marketplace plugin/mcp/skill installation
|
||||
plugin_author = install_info.get('plugin_author', '')
|
||||
plugin_name = install_info.get('plugin_name', '')
|
||||
@@ -511,7 +570,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
|
||||
if task_context:
|
||||
task_context.set_current_action('installing mcp server')
|
||||
await self._install_mcp_from_marketplace(mcp_data, task_context)
|
||||
await self._install_mcp_from_marketplace(execution_context, mcp_data, task_context)
|
||||
# Best-effort install report (bumps marketplace install_count).
|
||||
try:
|
||||
await client.post(
|
||||
@@ -554,7 +613,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
self.ap.logger.info(f'Downloaded skill ZIP ({file_size} bytes)')
|
||||
|
||||
# Install skill from ZIP using skill service
|
||||
await self._install_skill_from_zip(file_bytes, f'{plugin_author}-{plugin_name}', task_context)
|
||||
await self._install_skill_from_zip(
|
||||
execution_context,
|
||||
file_bytes,
|
||||
f'{plugin_author}-{plugin_name}',
|
||||
task_context,
|
||||
)
|
||||
return
|
||||
elif skill_resp.status_code == 404:
|
||||
# Try plugin endpoint - get versions and download
|
||||
@@ -759,6 +823,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
# Fetch all timestamps in a single query using OR conditions
|
||||
if plugin_ids:
|
||||
action_context = self.handler.require_bound_action_context()
|
||||
conditions = [
|
||||
sqlalchemy.and_(
|
||||
persistence_plugin.PluginSetting.plugin_author == author,
|
||||
@@ -772,7 +837,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
persistence_plugin.PluginSetting.plugin_author,
|
||||
persistence_plugin.PluginSetting.plugin_name,
|
||||
persistence_plugin.PluginSetting.created_at,
|
||||
).where(sqlalchemy.or_(*conditions))
|
||||
)
|
||||
.where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(sqlalchemy.or_(*conditions))
|
||||
)
|
||||
|
||||
for row in result:
|
||||
@@ -896,16 +963,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
session: provider_session.Session,
|
||||
query_id: int,
|
||||
bound_plugins: list[str] | None = None,
|
||||
query_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not self.is_enable_plugin:
|
||||
return {'error': 'Tool not found: plugin system is disabled'}
|
||||
|
||||
# Pass include_plugins to runtime for validation
|
||||
if not self._runtime_available():
|
||||
return {'error': 'Plugin runtime is temporarily unavailable'}
|
||||
|
||||
return await self._runtime_handler().call_tool(
|
||||
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
|
||||
return await self.handler.call_tool(
|
||||
tool_name,
|
||||
parameters,
|
||||
session.model_dump(serialize_as_any=True),
|
||||
query_id,
|
||||
query_uuid=query_uuid,
|
||||
include_plugins=bound_plugins,
|
||||
)
|
||||
|
||||
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlalchemy
|
||||
import traceback
|
||||
from typing import TypeVar
|
||||
|
||||
from . import requester
|
||||
import sqlalchemy
|
||||
|
||||
from ...api.http.context import (
|
||||
ExecutionContext,
|
||||
PrincipalContext,
|
||||
PrincipalType,
|
||||
RequestContext,
|
||||
)
|
||||
from ...api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ...core import app
|
||||
from ...discover import engine
|
||||
from . import token
|
||||
from ...entity.persistence import model as persistence_model
|
||||
from ...entity.errors import provider as provider_errors
|
||||
from ...entity.persistence import model as persistence_model
|
||||
from ...workspace.entities import WorkspaceExecutionBinding
|
||||
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
from . import requester, token
|
||||
|
||||
|
||||
_CacheKey = tuple[str, str, int, str]
|
||||
_ModelEntity = TypeVar(
|
||||
'_ModelEntity',
|
||||
persistence_model.LLMModel,
|
||||
persistence_model.EmbeddingModel,
|
||||
persistence_model.RerankModel,
|
||||
)
|
||||
|
||||
|
||||
class ModelManager:
|
||||
"""Model manager"""
|
||||
"""Workspace-scoped runtime provider and model cache."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
provider_dict: dict[str, requester.RuntimeProvider]
|
||||
"""运行时模型提供商字典, uuid -> RuntimeProvider"""
|
||||
|
||||
llm_models: list[requester.RuntimeLLMModel]
|
||||
|
||||
embedding_models: list[requester.RuntimeEmbeddingModel]
|
||||
|
||||
rerank_models: list[requester.RuntimeRerankModel]
|
||||
provider_dict: dict[_CacheKey, requester.RuntimeProvider]
|
||||
llm_model_dict: dict[_CacheKey, requester.RuntimeLLMModel]
|
||||
embedding_model_dict: dict[_CacheKey, requester.RuntimeEmbeddingModel]
|
||||
rerank_model_dict: dict[_CacheKey, requester.RuntimeRerankModel]
|
||||
|
||||
requester_components: list[engine.Component]
|
||||
|
||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self.llm_models = []
|
||||
self.embedding_models = []
|
||||
self.rerank_models = []
|
||||
self.provider_dict = {}
|
||||
self.llm_model_dict = {}
|
||||
self.embedding_model_dict = {}
|
||||
self.rerank_model_dict = {}
|
||||
self.requester_components = []
|
||||
self.requester_dict = {}
|
||||
|
||||
@@ -60,12 +75,75 @@ class ModelManager:
|
||||
return litellm_provider
|
||||
return None
|
||||
|
||||
async def initialize(self):
|
||||
@staticmethod
|
||||
def _context_from_binding(
|
||||
binding: WorkspaceExecutionBinding,
|
||||
*,
|
||||
trigger_principal: PrincipalContext | None = None,
|
||||
) -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
trigger_principal=trigger_principal,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(context: ExecutionContext, resource_uuid: str) -> _CacheKey:
|
||||
return (
|
||||
context.instance_uuid,
|
||||
context.workspace_uuid,
|
||||
context.placement_generation,
|
||||
resource_uuid,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_same_scope(
|
||||
expected: ExecutionContext,
|
||||
actual: ExecutionContext,
|
||||
*,
|
||||
resource: str,
|
||||
) -> None:
|
||||
if (
|
||||
actual.instance_uuid != expected.instance_uuid
|
||||
or actual.workspace_uuid != expected.workspace_uuid
|
||||
or actual.placement_generation != expected.placement_generation
|
||||
):
|
||||
raise WorkspaceInvariantError(f'{resource} runtime belongs to another Workspace execution scope')
|
||||
|
||||
@staticmethod
|
||||
def _ensure_entity_workspace(entity: object, context: ExecutionContext, *, resource: str) -> None:
|
||||
workspace_uuid = getattr(entity, 'workspace_uuid', None)
|
||||
if workspace_uuid != context.workspace_uuid:
|
||||
raise WorkspaceInvariantError(f'{resource} belongs to another Workspace')
|
||||
|
||||
async def resolve_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
"""Resolve and fence-check an explicit tenant context for runtime access."""
|
||||
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
expected_generation = None
|
||||
supplied_instance_uuid = None
|
||||
trigger_principal = None
|
||||
|
||||
if isinstance(context, (RequestContext, ExecutionContext)):
|
||||
expected_generation = context.placement_generation
|
||||
supplied_instance_uuid = context.instance_uuid
|
||||
trigger_principal = context.principal if isinstance(context, RequestContext) else context.trigger_principal
|
||||
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
workspace_uuid,
|
||||
expected_generation=expected_generation,
|
||||
)
|
||||
if supplied_instance_uuid is not None and supplied_instance_uuid != binding.instance_uuid:
|
||||
raise WorkspaceInvariantError('Runtime context belongs to another LangBot instance')
|
||||
|
||||
return self._context_from_binding(binding, trigger_principal=trigger_principal)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
|
||||
|
||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
|
||||
for component in self.requester_components:
|
||||
# Skip components that use litellm_provider (they will use litellmchat.py instead)
|
||||
litellm_provider = self._get_litellm_provider_from_manifest(component)
|
||||
if litellm_provider:
|
||||
self.ap.logger.debug(
|
||||
@@ -76,133 +154,151 @@ class ModelManager:
|
||||
requester_dict[component.metadata.name] = component.get_python_component_class()
|
||||
|
||||
self.requester_dict = requester_dict
|
||||
|
||||
await self.load_models_from_db()
|
||||
|
||||
# Check if space models service is disabled
|
||||
space_config = self.ap.instance_config.data.get('space', {})
|
||||
if space_config.get('disable_models_service', False):
|
||||
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
|
||||
return
|
||||
|
||||
# Space model synchronization is a legacy OSS-singleton facility. A
|
||||
# cloud instance must receive tenant model projections from its control
|
||||
# plane and must never infer one Workspace for this global operation.
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_local_execution_binding()
|
||||
except WorkspaceError as exc:
|
||||
self.ap.logger.info(f'Skipping LangBot Space model sync outside an OSS local Workspace: {exc}')
|
||||
return
|
||||
|
||||
sync_context = self._context_from_binding(
|
||||
binding,
|
||||
trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
|
||||
)
|
||||
sync_timeout = space_config.get('models_sync_timeout')
|
||||
try:
|
||||
if sync_timeout:
|
||||
await asyncio.wait_for(
|
||||
self.sync_new_models_from_space(),
|
||||
self.sync_new_models_from_space(sync_context),
|
||||
timeout=float(sync_timeout),
|
||||
)
|
||||
else:
|
||||
await self.sync_new_models_from_space()
|
||||
await self.sync_new_models_from_space(sync_context)
|
||||
except asyncio.TimeoutError:
|
||||
self.ap.logger.warning(f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.')
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
|
||||
self.ap.logger.warning(f' - Error: {e}')
|
||||
self.ap.logger.warning(f' - Error: {exc}')
|
||||
|
||||
async def load_models_from_db(self) -> None:
|
||||
"""Load every active projected Workspace into isolated runtime caches."""
|
||||
|
||||
async def load_models_from_db(self):
|
||||
"""Load models from database"""
|
||||
self.ap.logger.info('Loading models from db...')
|
||||
|
||||
self.llm_models = []
|
||||
self.embedding_models = []
|
||||
self.rerank_models = []
|
||||
self.provider_dict = {}
|
||||
self.llm_model_dict = {}
|
||||
self.embedding_model_dict = {}
|
||||
self.rerank_model_dict = {}
|
||||
contexts: dict[str, ExecutionContext] = {}
|
||||
|
||||
async def context_for(workspace_uuid: str | None) -> ExecutionContext:
|
||||
if not workspace_uuid:
|
||||
raise WorkspaceInvariantError('Runtime model resource has no Workspace')
|
||||
cached = contexts.get(workspace_uuid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
|
||||
resolved = self._context_from_binding(
|
||||
binding,
|
||||
trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
|
||||
)
|
||||
contexts[workspace_uuid] = resolved
|
||||
return resolved
|
||||
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider)
|
||||
)
|
||||
for provider in providers_result.all():
|
||||
for provider_entity in providers_result.all():
|
||||
try:
|
||||
runtime_provider = await self.load_provider(provider)
|
||||
self.provider_dict[provider.uuid] = runtime_provider
|
||||
except provider_errors.RequesterNotFoundError as e:
|
||||
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping provider {provider.uuid}')
|
||||
continue
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load provider {provider.uuid}: {e}\n{traceback.format_exc()}')
|
||||
context = await context_for(provider_entity.workspace_uuid)
|
||||
runtime_provider = await self._build_provider(context, provider_entity)
|
||||
self.provider_dict[self._cache_key(context, provider_entity.uuid)] = runtime_provider
|
||||
except provider_errors.RequesterNotFoundError as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Requester {exc.requester_name} not found, skipping provider {provider_entity.uuid}'
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.error(f'Failed to load provider {provider_entity.uuid}: {exc}\n{traceback.format_exc()}')
|
||||
|
||||
# Load LLM models
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
|
||||
llm_models = result.all()
|
||||
for llm_model in llm_models:
|
||||
try:
|
||||
provider = self.provider_dict.get(llm_model.provider_uuid)
|
||||
if provider is None:
|
||||
self.ap.logger.warning(f'Provider {llm_model.provider_uuid} not found for model {llm_model.uuid}')
|
||||
continue
|
||||
runtime_llm_model = await self.load_llm_model_with_provider(llm_model, provider)
|
||||
self.llm_models.append(runtime_llm_model)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
await self._load_model_kind(
|
||||
persistence_model.LLMModel,
|
||||
self.llm_model_dict,
|
||||
self._build_llm_model,
|
||||
context_for,
|
||||
)
|
||||
await self._load_model_kind(
|
||||
persistence_model.EmbeddingModel,
|
||||
self.embedding_model_dict,
|
||||
self._build_embedding_model,
|
||||
context_for,
|
||||
)
|
||||
await self._load_model_kind(
|
||||
persistence_model.RerankModel,
|
||||
self.rerank_model_dict,
|
||||
self._build_rerank_model,
|
||||
context_for,
|
||||
)
|
||||
|
||||
# Load embedding models
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
|
||||
embedding_models = result.all()
|
||||
for embedding_model in embedding_models:
|
||||
async def _load_model_kind(self, entity_type, cache: dict, builder, context_for) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(entity_type))
|
||||
for model_entity in result.all():
|
||||
try:
|
||||
provider = self.provider_dict.get(embedding_model.provider_uuid)
|
||||
context = await context_for(model_entity.workspace_uuid)
|
||||
provider = self.provider_dict.get(self._cache_key(context, model_entity.provider_uuid))
|
||||
if provider is None:
|
||||
self.ap.logger.warning(
|
||||
f'Provider {embedding_model.provider_uuid} not found for model {embedding_model.uuid}'
|
||||
f'Provider {model_entity.provider_uuid} not found for model {model_entity.uuid}'
|
||||
)
|
||||
continue
|
||||
runtime_embedding_model = await self.load_embedding_model_with_provider(embedding_model, provider)
|
||||
self.embedding_models.append(runtime_embedding_model)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
runtime_model = builder(context, model_entity, provider)
|
||||
cache[self._cache_key(context, model_entity.uuid)] = runtime_model
|
||||
except Exception as exc:
|
||||
self.ap.logger.error(f'Failed to load model {model_entity.uuid}: {exc}\n{traceback.format_exc()}')
|
||||
|
||||
# Load rerank models
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
|
||||
rerank_models = result.all()
|
||||
for rerank_model in rerank_models:
|
||||
try:
|
||||
provider = self.provider_dict.get(rerank_model.provider_uuid)
|
||||
if provider is None:
|
||||
self.ap.logger.warning(
|
||||
f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
|
||||
)
|
||||
continue
|
||||
runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
|
||||
self.rerank_models.append(runtime_rerank_model)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
|
||||
async def sync_new_models_from_space(self, context: ExecutionContext) -> None:
|
||||
"""Sync legacy Space models for the explicitly selected OSS Workspace."""
|
||||
|
||||
async def sync_new_models_from_space(self):
|
||||
"""Sync models from Space"""
|
||||
space_model_provider = await self.ap.persistence_mgr.execute_async(
|
||||
context = await self.resolve_execution_context(context)
|
||||
await self.ap.workspace_service.get_local_execution_binding(
|
||||
context.workspace_uuid,
|
||||
expected_generation=context.placement_generation,
|
||||
)
|
||||
space_model_provider_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.requester == 'space-chat-completions'
|
||||
persistence_model.ModelProvider.workspace_uuid == context.workspace_uuid,
|
||||
persistence_model.ModelProvider.requester == 'space-chat-completions',
|
||||
)
|
||||
)
|
||||
result = space_model_provider.first()
|
||||
if result is None:
|
||||
space_model_provider = space_model_provider_result.first()
|
||||
if space_model_provider is None:
|
||||
raise provider_errors.ProviderNotFoundError('LangBot Models')
|
||||
|
||||
space_model_provider = result
|
||||
|
||||
# get the latest models from space
|
||||
space_models = await self.ap.space_service.get_models()
|
||||
|
||||
# Index existing models by uuid. Space reuses a model's uuid across
|
||||
# renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
|
||||
# may later become ``claude-opus-4-7``). So for Space-managed models we
|
||||
# upsert: create when the uuid is new, otherwise update name/abilities/
|
||||
# ranking to track Space. Models owned by other providers are never
|
||||
# touched, even on an (unexpected) uuid collision.
|
||||
existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()}
|
||||
existing_llm_models = {
|
||||
model['uuid']: model
|
||||
for model in await self.ap.llm_model_service.get_llm_models(context, include_secret=True)
|
||||
}
|
||||
existing_embedding_models = {
|
||||
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
|
||||
model['uuid']: model
|
||||
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
|
||||
}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for space_model in space_models:
|
||||
if space_model.category == 'chat':
|
||||
existing = existing_llm_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
# model will be automatically loaded
|
||||
await self.ap.llm_model_service.create_llm_model(
|
||||
context,
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
@@ -227,14 +323,14 @@ class ModelManager:
|
||||
or list(existing.get('abilities') or []) != list(desired['abilities'])
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired))
|
||||
await self.ap.llm_model_service.update_llm_model(context, space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
elif space_model.category == 'embedding':
|
||||
existing = existing_embedding_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
# model will be automatically loaded
|
||||
await self.ap.embedding_models_service.create_embedding_model(
|
||||
context,
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
@@ -255,7 +351,11 @@ class ModelManager:
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
|
||||
await self.ap.embedding_models_service.update_embedding_model(
|
||||
context,
|
||||
space_model.uuid,
|
||||
dict(desired),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
@@ -263,313 +363,330 @@ class ModelManager:
|
||||
|
||||
async def init_temporary_runtime_llm_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_info: dict,
|
||||
) -> requester.RuntimeLLMModel:
|
||||
"""Initialize runtime LLM model from dict (for testing)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
|
||||
runtime_provider = await self.load_provider(provider_info)
|
||||
|
||||
runtime_llm_model = requester.RuntimeLLMModel(
|
||||
model_entity=persistence_model.LLMModel(
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid='',
|
||||
abilities=model_info.get('abilities', []),
|
||||
context_length=model_info.get('context_length'),
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
),
|
||||
provider=runtime_provider,
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
||||
runtime_provider = await self._build_provider(
|
||||
execution_context,
|
||||
persistence_model.ModelProvider(**provider_info),
|
||||
)
|
||||
|
||||
return runtime_llm_model
|
||||
model_entity = persistence_model.LLMModel(
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid=runtime_provider.provider_entity.uuid,
|
||||
abilities=model_info.get('abilities', []),
|
||||
context_length=model_info.get('context_length'),
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
)
|
||||
return self._build_llm_model(execution_context, model_entity, runtime_provider)
|
||||
|
||||
async def init_temporary_runtime_embedding_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_info: dict,
|
||||
) -> requester.RuntimeEmbeddingModel:
|
||||
"""Initialize runtime embedding model from dict (for testing)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
runtime_provider = await self.load_provider(provider_info)
|
||||
|
||||
runtime_embedding_model = requester.RuntimeEmbeddingModel(
|
||||
model_entity=persistence_model.EmbeddingModel(
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid='',
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
),
|
||||
provider=runtime_provider,
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
||||
runtime_provider = await self._build_provider(
|
||||
execution_context,
|
||||
persistence_model.ModelProvider(**provider_info),
|
||||
)
|
||||
|
||||
return runtime_embedding_model
|
||||
model_entity = persistence_model.EmbeddingModel(
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid=runtime_provider.provider_entity.uuid,
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
)
|
||||
return self._build_embedding_model(execution_context, model_entity, runtime_provider)
|
||||
|
||||
async def init_temporary_runtime_rerank_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_info: dict,
|
||||
) -> requester.RuntimeRerankModel:
|
||||
"""Initialize runtime rerank model from dict (for testing)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
runtime_provider = await self.load_provider(provider_info)
|
||||
|
||||
runtime_rerank_model = requester.RuntimeRerankModel(
|
||||
model_entity=persistence_model.RerankModel(
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid='',
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
),
|
||||
provider=runtime_provider,
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
||||
runtime_provider = await self._build_provider(
|
||||
execution_context,
|
||||
persistence_model.ModelProvider(**provider_info),
|
||||
)
|
||||
model_entity = persistence_model.RerankModel(
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid=runtime_provider.provider_entity.uuid,
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
)
|
||||
return self._build_rerank_model(execution_context, model_entity, runtime_provider)
|
||||
|
||||
return runtime_rerank_model
|
||||
|
||||
async def load_provider(
|
||||
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
|
||||
) -> requester.RuntimeProvider:
|
||||
"""Load provider from dict"""
|
||||
@staticmethod
|
||||
def _coerce_provider(
|
||||
provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
|
||||
context: ExecutionContext,
|
||||
) -> persistence_model.ModelProvider:
|
||||
if isinstance(provider_info, sqlalchemy.Row):
|
||||
provider_entity = persistence_model.ModelProvider(**provider_info._mapping)
|
||||
elif isinstance(provider_info, dict):
|
||||
provider_entity = persistence_model.ModelProvider(**provider_info)
|
||||
provider_entity = persistence_model.ModelProvider(
|
||||
**{**provider_info, 'workspace_uuid': context.workspace_uuid}
|
||||
)
|
||||
else:
|
||||
provider_entity = provider_info
|
||||
ModelManager._ensure_entity_workspace(provider_entity, context, resource='Provider')
|
||||
return provider_entity
|
||||
|
||||
# Get requester manifest to check for litellm_provider
|
||||
async def _build_provider(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
|
||||
) -> requester.RuntimeProvider:
|
||||
provider_entity = self._coerce_provider(provider_info, context)
|
||||
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
||||
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
|
||||
|
||||
# Build config from base_url
|
||||
config = {'base_url': provider_entity.base_url}
|
||||
|
||||
# Check if requester manifest specifies litellm_provider
|
||||
if litellm_provider:
|
||||
from .requesters import litellmchat
|
||||
|
||||
# Use unified LiteLLMRequester with provider prefix
|
||||
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
|
||||
config['custom_llm_provider'] = litellm_provider
|
||||
requester_inst = litellmchat.LiteLLMRequester(
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
requester_inst = litellmchat.LiteLLMRequester(ap=self.ap, config=config)
|
||||
self.ap.logger.debug(
|
||||
f'Using LiteLLMRequester for {provider_entity.requester} '
|
||||
f'with custom_llm_provider={config["custom_llm_provider"]}'
|
||||
)
|
||||
else:
|
||||
# Use original requester class (for backward compatibility)
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
requester_inst = self.requester_dict[provider_entity.requester](ap=self.ap, config=config)
|
||||
|
||||
await requester_inst.initialize()
|
||||
|
||||
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
|
||||
|
||||
provider = requester.RuntimeProvider(
|
||||
return requester.RuntimeProvider(
|
||||
execution_context=context,
|
||||
provider_entity=provider_entity,
|
||||
token_mgr=token_mgr,
|
||||
requester=requester_inst,
|
||||
)
|
||||
|
||||
async def load_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
|
||||
) -> requester.RuntimeProvider:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
return await self._build_provider(execution_context, provider_info)
|
||||
|
||||
async def cache_provider(self, context: TenantContext, provider: requester.RuntimeProvider) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self._ensure_same_scope(execution_context, provider.execution_context, resource='Provider')
|
||||
self._ensure_entity_workspace(provider.provider_entity, execution_context, resource='Provider')
|
||||
self.provider_dict[self._cache_key(execution_context, provider.provider_entity.uuid)] = provider
|
||||
|
||||
async def get_provider_by_uuid(
|
||||
self,
|
||||
context: TenantContext,
|
||||
provider_uuid: str,
|
||||
) -> requester.RuntimeProvider:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
provider = self.provider_dict.get(self._cache_key(execution_context, provider_uuid))
|
||||
if provider is None:
|
||||
raise ValueError(f'Model provider {provider_uuid} not found')
|
||||
self._ensure_same_scope(execution_context, provider.execution_context, resource='Provider')
|
||||
return provider
|
||||
|
||||
async def remove_provider(self, provider_uuid: str):
|
||||
"""Remove provider
|
||||
async def remove_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self.provider_dict.pop(self._cache_key(execution_context, provider_uuid), None)
|
||||
|
||||
This method will not consider the models using this provider,
|
||||
because the models should be removed by the caller.
|
||||
"""
|
||||
del self.provider_dict[provider_uuid]
|
||||
|
||||
async def reload_provider(self, provider_uuid: str):
|
||||
"""Reload provider"""
|
||||
provider_entity = await self.ap.persistence_mgr.execute_async(
|
||||
async def reload_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == provider_uuid,
|
||||
)
|
||||
)
|
||||
provider_entity = provider_entity.first()
|
||||
provider_entity = result.first()
|
||||
if provider_entity is None:
|
||||
raise provider_errors.ProviderNotFoundError(provider_uuid)
|
||||
|
||||
new_runtime_provider = await self.load_provider(provider_entity)
|
||||
new_provider = await self._build_provider(execution_context, provider_entity)
|
||||
cache_prefix = self._cache_key(execution_context, '')[:3]
|
||||
for cache in (self.llm_model_dict, self.embedding_model_dict, self.rerank_model_dict):
|
||||
for key, model in cache.items():
|
||||
if key[:3] == cache_prefix and model.provider.provider_entity.uuid == provider_uuid:
|
||||
model.provider = new_provider
|
||||
self.provider_dict[self._cache_key(execution_context, provider_uuid)] = new_provider
|
||||
|
||||
# update refs in runtime models
|
||||
for model in self.llm_models:
|
||||
if model.provider.provider_entity.uuid == provider_uuid:
|
||||
model.provider = new_runtime_provider
|
||||
for model in self.embedding_models:
|
||||
if model.provider.provider_entity.uuid == provider_uuid:
|
||||
model.provider = new_runtime_provider
|
||||
for model in self.rerank_models:
|
||||
if model.provider.provider_entity.uuid == provider_uuid:
|
||||
model.provider = new_runtime_provider
|
||||
@staticmethod
|
||||
def _coerce_model(model_info: _ModelEntity | sqlalchemy.Row, entity_type: type[_ModelEntity]) -> _ModelEntity:
|
||||
if isinstance(model_info, sqlalchemy.Row):
|
||||
return entity_type(**model_info._mapping)
|
||||
return model_info
|
||||
|
||||
# update ref in provider dict
|
||||
self.provider_dict[provider_uuid] = new_runtime_provider
|
||||
|
||||
async def load_llm_model_with_provider(
|
||||
def _validate_model_provider(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
model_entity: _ModelEntity,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> None:
|
||||
self._ensure_entity_workspace(model_entity, context, resource='Model')
|
||||
self._ensure_same_scope(context, provider.execution_context, resource='Provider')
|
||||
if model_entity.provider_uuid != provider.provider_entity.uuid:
|
||||
raise WorkspaceInvariantError('Model references a different provider')
|
||||
|
||||
def _build_llm_model(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
model_info: persistence_model.LLMModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeLLMModel:
|
||||
"""Load LLM model with provider info"""
|
||||
if isinstance(model_info, sqlalchemy.Row):
|
||||
model_info = persistence_model.LLMModel(**model_info._mapping)
|
||||
|
||||
runtime_llm_model = requester.RuntimeLLMModel(
|
||||
model_entity=model_info,
|
||||
model_entity = self._coerce_model(model_info, persistence_model.LLMModel)
|
||||
self._validate_model_provider(context, model_entity, provider)
|
||||
return requester.RuntimeLLMModel(
|
||||
execution_context=context,
|
||||
model_entity=model_entity,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return runtime_llm_model
|
||||
|
||||
async def load_embedding_model_with_provider(
|
||||
def _build_embedding_model(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeEmbeddingModel:
|
||||
"""Load embedding model with provider info"""
|
||||
if isinstance(model_info, sqlalchemy.Row):
|
||||
model_info = persistence_model.EmbeddingModel(**model_info._mapping)
|
||||
|
||||
runtime_embedding_model = requester.RuntimeEmbeddingModel(
|
||||
model_entity=model_info,
|
||||
model_entity = self._coerce_model(model_info, persistence_model.EmbeddingModel)
|
||||
self._validate_model_provider(context, model_entity, provider)
|
||||
return requester.RuntimeEmbeddingModel(
|
||||
execution_context=context,
|
||||
model_entity=model_entity,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return runtime_embedding_model
|
||||
|
||||
async def load_rerank_model_with_provider(
|
||||
def _build_rerank_model(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
model_info: persistence_model.RerankModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeRerankModel:
|
||||
"""Load rerank model with provider info"""
|
||||
if isinstance(model_info, sqlalchemy.Row):
|
||||
model_info = persistence_model.RerankModel(**model_info._mapping)
|
||||
|
||||
runtime_rerank_model = requester.RuntimeRerankModel(
|
||||
model_entity=model_info,
|
||||
model_entity = self._coerce_model(model_info, persistence_model.RerankModel)
|
||||
self._validate_model_provider(context, model_entity, provider)
|
||||
return requester.RuntimeRerankModel(
|
||||
execution_context=context,
|
||||
model_entity=model_entity,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return runtime_rerank_model
|
||||
async def load_llm_model_with_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_info: persistence_model.LLMModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeLLMModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
return self._build_llm_model(execution_context, model_info, provider)
|
||||
|
||||
async def load_llm_model(self, model_info: dict):
|
||||
"""Load LLM model from dict (with provider info)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
if not provider_info:
|
||||
raise ValueError('Provider info is required')
|
||||
async def load_embedding_model_with_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeEmbeddingModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
return self._build_embedding_model(execution_context, model_info, provider)
|
||||
|
||||
model_entity = persistence_model.LLMModel(
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid=model_info.get('provider_uuid', ''),
|
||||
abilities=model_info.get('abilities', []),
|
||||
context_length=model_info.get('context_length'),
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
)
|
||||
async def load_rerank_model_with_provider(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_info: persistence_model.RerankModel | sqlalchemy.Row,
|
||||
provider: requester.RuntimeProvider,
|
||||
) -> requester.RuntimeRerankModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
return self._build_rerank_model(execution_context, model_info, provider)
|
||||
|
||||
provider_entity = persistence_model.ModelProvider(
|
||||
uuid=provider_info.get('uuid', ''),
|
||||
name=provider_info.get('name', ''),
|
||||
requester=provider_info.get('requester', ''),
|
||||
base_url=provider_info.get('base_url', ''),
|
||||
api_keys=provider_info.get('api_keys', []),
|
||||
)
|
||||
async def cache_llm_model(self, context: TenantContext, model: requester.RuntimeLLMModel) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self._ensure_same_scope(execution_context, model.execution_context, resource='LLM model')
|
||||
self.llm_model_dict[self._cache_key(execution_context, model.model_entity.uuid)] = model
|
||||
|
||||
await self.load_llm_model_with_provider(model_entity, provider_entity)
|
||||
async def cache_embedding_model(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model: requester.RuntimeEmbeddingModel,
|
||||
) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self._ensure_same_scope(execution_context, model.execution_context, resource='Embedding model')
|
||||
self.embedding_model_dict[self._cache_key(execution_context, model.model_entity.uuid)] = model
|
||||
|
||||
async def load_embedding_model(self, model_info: dict):
|
||||
"""Load embedding model from dict (with provider info)"""
|
||||
provider_info = model_info.get('provider', {})
|
||||
if not provider_info:
|
||||
raise ValueError('Provider info is required')
|
||||
async def cache_rerank_model(self, context: TenantContext, model: requester.RuntimeRerankModel) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self._ensure_same_scope(execution_context, model.execution_context, resource='Rerank model')
|
||||
self.rerank_model_dict[self._cache_key(execution_context, model.model_entity.uuid)] = model
|
||||
|
||||
model_entity = persistence_model.EmbeddingModel(
|
||||
uuid=model_info.get('uuid', ''),
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid=model_info.get('provider_uuid', ''),
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
)
|
||||
async def get_model_by_uuid(self, context: TenantContext, model_uuid: str) -> requester.RuntimeLLMModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
model = self.llm_model_dict.get(self._cache_key(execution_context, model_uuid))
|
||||
if model is None:
|
||||
raise ValueError(f'LLM model {model_uuid} not found')
|
||||
self._ensure_same_scope(execution_context, model.execution_context, resource='LLM model')
|
||||
return model
|
||||
|
||||
provider_entity = persistence_model.ModelProvider(
|
||||
uuid=provider_info.get('uuid', ''),
|
||||
name=provider_info.get('name', ''),
|
||||
requester=provider_info.get('requester', ''),
|
||||
base_url=provider_info.get('base_url', ''),
|
||||
api_keys=provider_info.get('api_keys', []),
|
||||
)
|
||||
async def get_embedding_model_by_uuid(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
) -> requester.RuntimeEmbeddingModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
model = self.embedding_model_dict.get(self._cache_key(execution_context, model_uuid))
|
||||
if model is None:
|
||||
raise ValueError(f'Embedding model {model_uuid} not found')
|
||||
self._ensure_same_scope(execution_context, model.execution_context, resource='Embedding model')
|
||||
return model
|
||||
|
||||
await self.load_embedding_model_with_provider(model_entity, provider_entity)
|
||||
async def get_rerank_model_by_uuid(
|
||||
self,
|
||||
context: TenantContext,
|
||||
model_uuid: str,
|
||||
) -> requester.RuntimeRerankModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
model = self.rerank_model_dict.get(self._cache_key(execution_context, model_uuid))
|
||||
if model is None:
|
||||
raise ValueError(f'Rerank model {model_uuid} not found')
|
||||
self._ensure_same_scope(execution_context, model.execution_context, resource='Rerank model')
|
||||
return model
|
||||
|
||||
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
|
||||
"""Get LLM model by uuid"""
|
||||
for model in self.llm_models:
|
||||
if model.model_entity.uuid == uuid:
|
||||
return model
|
||||
raise ValueError(f'LLM model {uuid} not found')
|
||||
async def remove_llm_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self.llm_model_dict.pop(self._cache_key(execution_context, model_uuid), None)
|
||||
|
||||
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
|
||||
"""Get embedding model by uuid"""
|
||||
for model in self.embedding_models:
|
||||
if model.model_entity.uuid == uuid:
|
||||
return model
|
||||
raise ValueError(f'Embedding model {uuid} not found')
|
||||
async def remove_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self.embedding_model_dict.pop(self._cache_key(execution_context, model_uuid), None)
|
||||
|
||||
async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
|
||||
"""Get rerank model by uuid"""
|
||||
for model in self.rerank_models:
|
||||
if model.model_entity.uuid == uuid:
|
||||
return model
|
||||
raise ValueError(f'Rerank model {uuid} not found')
|
||||
|
||||
async def remove_llm_model(self, model_uuid: str):
|
||||
"""Remove LLM model"""
|
||||
for model in self.llm_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
self.llm_models.remove(model)
|
||||
return
|
||||
|
||||
async def remove_embedding_model(self, model_uuid: str):
|
||||
"""Remove embedding model"""
|
||||
for model in self.embedding_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
self.embedding_models.remove(model)
|
||||
return
|
||||
|
||||
async def remove_rerank_model(self, model_uuid: str):
|
||||
"""Remove rerank model"""
|
||||
for model in self.rerank_models:
|
||||
if model.model_entity.uuid == model_uuid:
|
||||
self.rerank_models.remove(model)
|
||||
return
|
||||
async def remove_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
self.rerank_model_dict.pop(self._cache_key(execution_context, model_uuid), None)
|
||||
|
||||
def get_available_requesters_info(self, model_type: str) -> list[dict]:
|
||||
"""Get all available requesters"""
|
||||
if model_type != '':
|
||||
if model_type:
|
||||
return [
|
||||
component.to_plain_dict()
|
||||
for component in self.requester_components
|
||||
if model_type in component.spec['support_type']
|
||||
]
|
||||
else:
|
||||
return [component.to_plain_dict() for component in self.requester_components]
|
||||
return [component.to_plain_dict() for component in self.requester_components]
|
||||
|
||||
def get_available_requester_info_by_name(self, name: str) -> dict | None:
|
||||
"""Get requester info by name"""
|
||||
for component in self.requester_components:
|
||||
if component.metadata.name == name:
|
||||
return component.to_plain_dict()
|
||||
return None
|
||||
|
||||
def get_available_requester_manifest_by_name(self, name: str) -> engine.Component | None:
|
||||
"""Get requester manifest by name"""
|
||||
for component in self.requester_components:
|
||||
if component.metadata.name == name:
|
||||
return component
|
||||
|
||||
@@ -5,7 +5,9 @@ import typing
|
||||
import time
|
||||
|
||||
from ...core import app
|
||||
from ...api.http.context import ExecutionContext
|
||||
from ...entity.persistence import model as persistence_model
|
||||
from ...workspace.errors import WorkspaceInvariantError
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from . import token
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -16,6 +18,20 @@ LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
|
||||
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
|
||||
|
||||
|
||||
def _ensure_same_execution_scope(
|
||||
expected: ExecutionContext,
|
||||
actual: ExecutionContext,
|
||||
*,
|
||||
resource: str,
|
||||
) -> None:
|
||||
if (
|
||||
actual.instance_uuid != expected.instance_uuid
|
||||
or actual.workspace_uuid != expected.workspace_uuid
|
||||
or actual.placement_generation != expected.placement_generation
|
||||
):
|
||||
raise WorkspaceInvariantError(f'{resource} belongs to another Workspace execution scope')
|
||||
|
||||
|
||||
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
|
||||
"""Store the latest provider usage on the query for upstream action handlers."""
|
||||
if query is None or not usage_info:
|
||||
@@ -39,24 +55,61 @@ class RuntimeProvider:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
provider_entity: persistence_model.ModelProvider,
|
||||
token_mgr: token.TokenManager,
|
||||
requester: ProviderAPIRequester,
|
||||
):
|
||||
if provider_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceInvariantError('Provider belongs to another Workspace')
|
||||
self.execution_context = execution_context
|
||||
self.provider_entity = provider_entity
|
||||
self.token_mgr = token_mgr
|
||||
self.requester = requester
|
||||
|
||||
def _validate_invocation(
|
||||
self,
|
||||
model: RuntimeLLMModel | RuntimeEmbeddingModel | RuntimeRerankModel,
|
||||
execution_context: ExecutionContext,
|
||||
) -> None:
|
||||
_ensure_same_execution_scope(self.execution_context, execution_context, resource='Provider invocation')
|
||||
_ensure_same_execution_scope(self.execution_context, model.execution_context, resource='Runtime model')
|
||||
if model.provider is not self:
|
||||
raise WorkspaceInvariantError('Runtime model is attached to another provider')
|
||||
|
||||
def _resolve_llm_execution_context(
|
||||
self,
|
||||
query: pipeline_query.Query | None,
|
||||
execution_context: ExecutionContext | None,
|
||||
) -> ExecutionContext:
|
||||
if query is not None:
|
||||
from ...pipeline.pool import get_query_execution_context
|
||||
|
||||
query_context = get_query_execution_context(query)
|
||||
if execution_context is not None:
|
||||
_ensure_same_execution_scope(
|
||||
query_context,
|
||||
execution_context,
|
||||
resource='Explicit LLM invocation context',
|
||||
)
|
||||
return query_context
|
||||
if execution_context is None:
|
||||
raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
|
||||
return execution_context
|
||||
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
query: pipeline_query.Query | None,
|
||||
model: RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
execution_context: ExecutionContext | None = None,
|
||||
) -> provider_message.Message:
|
||||
"""Bridge method for invoking LLM with monitoring"""
|
||||
invocation_context = self._resolve_llm_execution_context(query, execution_context)
|
||||
self._validate_invocation(model, invocation_context)
|
||||
# Start timing for monitoring
|
||||
start_time = time.time()
|
||||
input_tokens = 0
|
||||
@@ -130,14 +183,17 @@ class RuntimeProvider:
|
||||
|
||||
async def invoke_llm_stream(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
query: pipeline_query.Query | None,
|
||||
model: RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
execution_context: ExecutionContext | None = None,
|
||||
) -> provider_message.MessageChunk:
|
||||
"""Bridge method for invoking LLM stream with monitoring"""
|
||||
invocation_context = self._resolve_llm_execution_context(query, execution_context)
|
||||
self._validate_invocation(model, invocation_context)
|
||||
# Start timing for monitoring
|
||||
start_time = time.time()
|
||||
status = 'success'
|
||||
@@ -212,6 +268,8 @@ class RuntimeProvider:
|
||||
model: RuntimeEmbeddingModel,
|
||||
input_text: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
*,
|
||||
execution_context: ExecutionContext,
|
||||
knowledge_base_id: str | None = None,
|
||||
query_text: str | None = None,
|
||||
session_id: str | None = None,
|
||||
@@ -219,6 +277,7 @@ class RuntimeProvider:
|
||||
call_type: str | None = None,
|
||||
) -> typing.List[typing.List[float]]:
|
||||
"""Bridge method for invoking embedding with monitoring"""
|
||||
self._validate_invocation(model, execution_context)
|
||||
# Start timing for monitoring
|
||||
start_time = time.time()
|
||||
prompt_tokens = 0
|
||||
@@ -254,6 +313,7 @@ class RuntimeProvider:
|
||||
|
||||
try:
|
||||
await self.requester.ap.monitoring_service.record_embedding_call(
|
||||
execution_context,
|
||||
model_name=model.model_entity.name,
|
||||
prompt_tokens=prompt_tokens,
|
||||
total_tokens=total_tokens,
|
||||
@@ -276,8 +336,11 @@ class RuntimeProvider:
|
||||
query: str,
|
||||
documents: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
*,
|
||||
execution_context: ExecutionContext,
|
||||
) -> typing.List[dict]:
|
||||
"""Bridge method for invoking rerank with monitoring"""
|
||||
self._validate_invocation(model, execution_context)
|
||||
start_time = time.time()
|
||||
status = 'success'
|
||||
|
||||
@@ -316,9 +379,16 @@ class RuntimeLLMModel:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
model_entity: persistence_model.LLMModel,
|
||||
provider: RuntimeProvider,
|
||||
):
|
||||
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
|
||||
if model_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceInvariantError('LLM model belongs to another Workspace')
|
||||
if model_entity.provider_uuid != provider.provider_entity.uuid:
|
||||
raise WorkspaceInvariantError('LLM model references another provider')
|
||||
self.execution_context = execution_context
|
||||
self.model_entity = model_entity
|
||||
self.provider = provider
|
||||
|
||||
@@ -334,9 +404,16 @@ class RuntimeEmbeddingModel:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
model_entity: persistence_model.EmbeddingModel,
|
||||
provider: RuntimeProvider,
|
||||
):
|
||||
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='Embedding model')
|
||||
if model_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceInvariantError('Embedding model belongs to another Workspace')
|
||||
if model_entity.provider_uuid != provider.provider_entity.uuid:
|
||||
raise WorkspaceInvariantError('Embedding model references another provider')
|
||||
self.execution_context = execution_context
|
||||
self.model_entity = model_entity
|
||||
self.provider = provider
|
||||
|
||||
@@ -352,9 +429,16 @@ class RuntimeRerankModel:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
model_entity: persistence_model.RerankModel,
|
||||
provider: RuntimeProvider,
|
||||
):
|
||||
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='Rerank model')
|
||||
if model_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceInvariantError('Rerank model belongs to another Workspace')
|
||||
if model_entity.provider_uuid != provider.provider_entity.uuid:
|
||||
raise WorkspaceInvariantError('Rerank model references another provider')
|
||||
self.execution_context = execution_context
|
||||
self.model_entity = model_entity
|
||||
self.provider = provider
|
||||
|
||||
|
||||
@@ -22,10 +22,12 @@ from langbot.libs.dify_service_api.v1 import client, errors
|
||||
import httpx
|
||||
|
||||
|
||||
# Module-level store for paused-workflow form state. The key isolates the bot,
|
||||
# pipeline, adapter, and launcher; each value holds an insertion-ordered map of
|
||||
# form_token -> form_data so one conversation can pause multiple workflows.
|
||||
PendingFormKey = tuple[str, str, str, str, str]
|
||||
# Module-level store for paused-workflow form state. The key includes the full
|
||||
# execution scope before the bot, pipeline, adapter, and launcher dimensions;
|
||||
# each value holds an insertion-ordered map of form_token -> form_data so one
|
||||
# conversation can pause multiple workflows without crossing Workspaces or
|
||||
# placement generations.
|
||||
PendingFormKey = tuple[str, str, int, str, str, str, str, str]
|
||||
_PENDING_FORMS: dict[PendingFormKey, 'OrderedDict[str, dict[str, typing.Any]]'] = {}
|
||||
_PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap
|
||||
_STREAM_FORM_PLACEHOLDER = '\u200b'
|
||||
@@ -48,10 +50,13 @@ def _dify_user_from_query(query: pipeline_query.Query) -> str:
|
||||
|
||||
|
||||
def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey:
|
||||
"""Build a process-local pending-form key isolated by bot and pipeline."""
|
||||
"""Build a process-local pending-form key isolated by execution scope."""
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
adapter_type = f'{type(adapter).__module__}.{type(adapter).__qualname__}'
|
||||
return (
|
||||
str(getattr(query, 'instance_uuid', '') or ''),
|
||||
str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
int(getattr(query, 'placement_generation', 0) or 0),
|
||||
str(getattr(query, 'bot_uuid', '') or ''),
|
||||
str(getattr(query, 'pipeline_uuid', '') or ''),
|
||||
adapter_type,
|
||||
@@ -74,8 +79,8 @@ def _prune_pending_forms(now: float | None = None) -> None:
|
||||
|
||||
def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.Any]) -> None:
|
||||
_prune_pending_forms()
|
||||
if isinstance(session_key, tuple) and len(session_key) > 1:
|
||||
form_data['pipeline_uuid'] = session_key[1]
|
||||
if isinstance(session_key, tuple) and len(session_key) == 8:
|
||||
form_data['pipeline_uuid'] = session_key[4]
|
||||
stored = dict(form_data)
|
||||
expiration_time = stored.get('expiration_time')
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||
|
||||
from ...pipeline.pool import get_query_execution_context
|
||||
|
||||
rag_combined_prompt_template = """
|
||||
The following are relevant context entries retrieved from the knowledge base.
|
||||
@@ -227,7 +228,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
# Primary model
|
||||
if query.use_llm_model_uuid:
|
||||
try:
|
||||
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
primary = await self.ap.model_mgr.get_model_by_uuid(
|
||||
get_query_execution_context(query),
|
||||
query.use_llm_model_uuid,
|
||||
)
|
||||
candidates.append(primary)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
||||
@@ -236,7 +240,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
||||
for fb_uuid in fallback_uuids:
|
||||
try:
|
||||
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||
fb_model = await self.ap.model_mgr.get_model_by_uuid(
|
||||
get_query_execution_context(query),
|
||||
fb_uuid,
|
||||
)
|
||||
candidates.append(fb_model)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||
@@ -346,12 +353,13 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
if kb_uuids and user_message_text:
|
||||
# only support text for now
|
||||
all_results: list[rag_context.RetrievalResultEntry] = []
|
||||
execution_context = get_query_execution_context(query)
|
||||
|
||||
kb_engine_plugins: set[str] = set()
|
||||
|
||||
# Retrieve from each knowledge base
|
||||
for kb_uuid in kb_uuids:
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
|
||||
|
||||
if not kb:
|
||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||
@@ -364,6 +372,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
kb_engine_plugins.add(engine_plugin_id)
|
||||
|
||||
result = await kb.retrieve(
|
||||
execution_context,
|
||||
user_message_text,
|
||||
settings={
|
||||
'bot_uuid': query.bot_uuid or '',
|
||||
@@ -398,7 +407,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
)
|
||||
if all_results and rerank_model_uuid:
|
||||
try:
|
||||
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
|
||||
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(
|
||||
execution_context,
|
||||
rerank_model_uuid,
|
||||
)
|
||||
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
|
||||
|
||||
doc_texts = []
|
||||
@@ -411,6 +423,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
model=rerank_model,
|
||||
query=user_message_text,
|
||||
documents=doc_texts_capped,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
|
||||
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
|
||||
from ...core import app
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message, prompt as provider_prompt
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
|
||||
from ...api.http.context import ExecutionContext
|
||||
from ...core import app
|
||||
from ...pipeline.pool import (
|
||||
ExecutionContextMismatchError,
|
||||
ExecutionContextRequiredError,
|
||||
bind_execution_context,
|
||||
get_query_execution_context,
|
||||
)
|
||||
|
||||
SessionKey = tuple[
|
||||
str,
|
||||
str,
|
||||
int,
|
||||
str,
|
||||
str,
|
||||
int | str,
|
||||
]
|
||||
|
||||
|
||||
def _query_session_key(query: pipeline_query.Query) -> tuple[SessionKey, ExecutionContext]:
|
||||
execution_context = get_query_execution_context(query)
|
||||
bot_uuid = getattr(query, 'bot_uuid', None)
|
||||
if not isinstance(bot_uuid, str) or not bot_uuid.strip():
|
||||
raise ExecutionContextRequiredError('Query.bot_uuid is required for session lookup')
|
||||
|
||||
execution_context = bind_execution_context(execution_context, bot_uuid=bot_uuid)
|
||||
key: SessionKey = (
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
execution_context.placement_generation,
|
||||
bot_uuid,
|
||||
query.launcher_type.value,
|
||||
query.launcher_id,
|
||||
)
|
||||
return key, execution_context
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""会话管理器"""
|
||||
@@ -24,17 +60,39 @@ class SessionManager:
|
||||
|
||||
async def get_session(self, query: pipeline_query.Query) -> provider_session.Session:
|
||||
"""获取会话"""
|
||||
session_key, execution_context = _query_session_key(query)
|
||||
for session in self.session_list:
|
||||
if query.launcher_type == session.launcher_type and query.launcher_id == session.launcher_id:
|
||||
if getattr(session, '_langbot_session_key', None) == session_key:
|
||||
return session
|
||||
|
||||
session_concurrency = self.ap.instance_config.data['concurrency']['session']
|
||||
|
||||
session = provider_session.Session(
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
bot_uuid=query.bot_uuid,
|
||||
launcher_type=query.launcher_type,
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
)
|
||||
session_context = dataclasses.replace(
|
||||
execution_context,
|
||||
pipeline_uuid=None,
|
||||
query_uuid=None,
|
||||
)
|
||||
# langbot-plugin 0.4.13 ignores Workspace fields. Preserve them until
|
||||
# the Workspace-aware SDK becomes the minimum supported version.
|
||||
object.__setattr__(session, 'instance_uuid', session_context.instance_uuid)
|
||||
object.__setattr__(session, 'workspace_uuid', session_context.workspace_uuid)
|
||||
object.__setattr__(
|
||||
session,
|
||||
'placement_generation',
|
||||
session_context.placement_generation,
|
||||
)
|
||||
object.__setattr__(session, 'bot_uuid', query.bot_uuid)
|
||||
object.__setattr__(session, '_execution_context', session_context)
|
||||
object.__setattr__(session, '_langbot_session_key', session_key)
|
||||
session._semaphore = asyncio.Semaphore(session_concurrency)
|
||||
self.session_list.append(session)
|
||||
return session
|
||||
@@ -49,6 +107,17 @@ class SessionManager:
|
||||
) -> provider_session.Conversation:
|
||||
"""获取对话或创建对话"""
|
||||
|
||||
session_key, execution_context = _query_session_key(query)
|
||||
if getattr(session, '_langbot_session_key', None) != session_key:
|
||||
raise ExecutionContextMismatchError('Session does not belong to the Query execution scope')
|
||||
execution_context = bind_execution_context(
|
||||
execution_context,
|
||||
bot_uuid=bot_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
if execution_context.bot_uuid != getattr(session, 'bot_uuid', None):
|
||||
raise ExecutionContextMismatchError('Session bot_uuid does not match the Query execution scope')
|
||||
|
||||
if not session.conversations:
|
||||
session.conversations = []
|
||||
|
||||
@@ -63,7 +132,11 @@ class SessionManager:
|
||||
messages=prompt_messages,
|
||||
)
|
||||
|
||||
if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid:
|
||||
if (
|
||||
session.using_conversation is None
|
||||
or session.using_conversation.pipeline_uuid != pipeline_uuid
|
||||
or session.using_conversation.bot_uuid != bot_uuid
|
||||
):
|
||||
conversation = provider_session.Conversation(
|
||||
prompt=prompt,
|
||||
messages=[],
|
||||
|
||||
@@ -11,7 +11,7 @@ async def is_box_backend_available(ap: Any) -> bool:
|
||||
if not getattr(box_service, 'available', False):
|
||||
return False
|
||||
try:
|
||||
status = await box_service.get_status()
|
||||
status = await box_service.get_backend_status()
|
||||
backend_info = status.get('backend', {})
|
||||
return bool(backend_info.get('available', False))
|
||||
except Exception:
|
||||
|
||||
@@ -26,6 +26,9 @@ from pydantic import AnyUrl
|
||||
|
||||
from .. import loader
|
||||
from ....core import app
|
||||
from ....api.http.context import ExecutionContext
|
||||
from ....api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ....workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
@@ -223,6 +226,8 @@ class MCPToolCallTimeoutError(TimeoutError):
|
||||
class RuntimeMCPSession:
|
||||
"""运行时 MCP 会话"""
|
||||
|
||||
_FENCE_POLL_INTERVAL = 5.0
|
||||
|
||||
ap: app.Application
|
||||
|
||||
server_name: str
|
||||
@@ -262,11 +267,19 @@ class RuntimeMCPSession:
|
||||
|
||||
_box_stdio_runtime: BoxStdioSessionRuntime
|
||||
|
||||
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
||||
def __init__(
|
||||
self,
|
||||
server_name: str,
|
||||
server_config: dict,
|
||||
enable: bool,
|
||||
ap: app.Application,
|
||||
execution_context: ExecutionContext,
|
||||
):
|
||||
self.server_name = server_name
|
||||
self.server_uuid = server_config.get('uuid', '')
|
||||
self.server_config = server_config
|
||||
self.ap = ap
|
||||
self.execution_context = execution_context
|
||||
self.enable = enable
|
||||
self.session = None
|
||||
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
|
||||
@@ -312,24 +325,46 @@ class RuntimeMCPSession:
|
||||
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
|
||||
self.box_config = self._box_stdio_runtime.config
|
||||
|
||||
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
|
||||
"""Return a safe tool-call timeout; zero explicitly disables it."""
|
||||
try:
|
||||
timeout = -1 if isinstance(value, bool) else float(value)
|
||||
if timeout > 0:
|
||||
# Validate the exact conversion used for each call here, so a
|
||||
# finite-but-enormous manual config cannot fail at invocation.
|
||||
timedelta(seconds=timeout)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
timeout = -1
|
||||
async def _assert_execution_active(self) -> None:
|
||||
"""Fail closed when this long-lived session belongs to a stale placement."""
|
||||
|
||||
if not math.isfinite(timeout) or timeout < 0:
|
||||
self.ap.logger.warning(
|
||||
f'Invalid MCP tool call timeout {value!r} for {self.server_name}; '
|
||||
f'using {MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS:g} seconds'
|
||||
)
|
||||
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
|
||||
return timeout
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
self.execution_context.workspace_uuid,
|
||||
expected_generation=self.execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != self.execution_context.instance_uuid:
|
||||
raise WorkspaceInvariantError('MCP session instance does not match the active Workspace binding')
|
||||
|
||||
async def _monitor_execution_fence(self) -> None:
|
||||
"""Poll the placement fence while an MCP transport is idle."""
|
||||
|
||||
while not self._shutdown_event.is_set():
|
||||
await asyncio.sleep(self._FENCE_POLL_INTERVAL)
|
||||
if self._shutdown_event.is_set():
|
||||
return
|
||||
await self._assert_execution_active()
|
||||
|
||||
async def _sleep_with_execution_fence(self, delay: float) -> None:
|
||||
"""Back off without reconnecting after the captured placement expires."""
|
||||
|
||||
await self._assert_execution_active()
|
||||
try:
|
||||
await asyncio.wait_for(self._shutdown_event.wait(), timeout=delay)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if not self._shutdown_event.is_set():
|
||||
await self._assert_execution_active()
|
||||
|
||||
def _stop_for_stale_execution(self, error: WorkspaceError) -> None:
|
||||
"""Mark the session terminal without retrying a fenced placement."""
|
||||
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = 'Workspace execution binding is stale'
|
||||
self._shutdown_event.set()
|
||||
self._ready_event.set()
|
||||
self.ap.logger.info(
|
||||
f'MCP session {self.server_name} stopped because its Workspace execution binding is stale: {error}'
|
||||
)
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
if self._uses_box_stdio():
|
||||
@@ -458,6 +493,7 @@ class RuntimeMCPSession:
|
||||
async def _lifecycle_loop(self):
|
||||
"""Manage the full MCP session lifecycle in a background task."""
|
||||
try:
|
||||
await self._assert_execution_active()
|
||||
if self.server_config['mode'] == 'stdio':
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'remote':
|
||||
@@ -467,9 +503,11 @@ class RuntimeMCPSession:
|
||||
elif self.server_config['mode'] == 'http':
|
||||
await self._init_streamable_http_server()
|
||||
else:
|
||||
raise ValueError(f'Unknown MCP server mode: {self.server_name}: {self.server_config}')
|
||||
raise ValueError(f'Unknown MCP server mode for {self.server_name}')
|
||||
|
||||
await self._assert_execution_active()
|
||||
await self.refresh()
|
||||
await self._assert_execution_active()
|
||||
|
||||
self.status = MCPSessionStatus.CONNECTED
|
||||
|
||||
@@ -481,12 +519,16 @@ class RuntimeMCPSession:
|
||||
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
|
||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
||||
fence_task = asyncio.create_task(self._monitor_execution_fence())
|
||||
done, pending = await asyncio.wait(
|
||||
[shutdown_task, monitor_task, reconnect_task],
|
||||
[shutdown_task, monitor_task, reconnect_task, fence_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
if fence_task in done and not self._shutdown_event.is_set():
|
||||
fence_task.result()
|
||||
if reconnect_task in done and not self._shutdown_event.is_set():
|
||||
self._reconnect_event.clear()
|
||||
self.ap.logger.info(
|
||||
@@ -522,12 +564,16 @@ class RuntimeMCPSession:
|
||||
else:
|
||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
||||
fence_task = asyncio.create_task(self._monitor_execution_fence())
|
||||
done, pending = await asyncio.wait(
|
||||
[shutdown_task, reconnect_task],
|
||||
[shutdown_task, reconnect_task, fence_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
if fence_task in done and not self._shutdown_event.is_set():
|
||||
fence_task.result()
|
||||
if reconnect_task in done and not self._shutdown_event.is_set():
|
||||
self._reconnect_event.clear()
|
||||
self.ap.logger.info(
|
||||
@@ -590,7 +636,11 @@ class RuntimeMCPSession:
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
await self._sleep_with_execution_fence(1)
|
||||
except WorkspaceError as fence_error:
|
||||
self._stop_for_stale_execution(fence_error)
|
||||
return
|
||||
continue
|
||||
except _CallerReconnect:
|
||||
# A tool/resource call hit a server-expired session and asked us
|
||||
@@ -607,6 +657,7 @@ class RuntimeMCPSession:
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
try:
|
||||
await self._assert_execution_active()
|
||||
if self.server_config['mode'] == 'stdio':
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'remote':
|
||||
@@ -616,8 +667,12 @@ class RuntimeMCPSession:
|
||||
elif self.server_config['mode'] == 'http':
|
||||
await self._init_streamable_http_server()
|
||||
await self.refresh()
|
||||
await self._assert_execution_active()
|
||||
self.status = MCPSessionStatus.CONNECTED
|
||||
self.ap.logger.info(f'MCP session {self.server_name} reconnected successfully after session expiry')
|
||||
except WorkspaceError as reconnect_err:
|
||||
self._stop_for_stale_execution(reconnect_err)
|
||||
return
|
||||
except Exception as reconnect_err:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(reconnect_err)
|
||||
@@ -645,8 +700,15 @@ class RuntimeMCPSession:
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
await self._sleep_with_execution_fence(2)
|
||||
except WorkspaceError as fence_error:
|
||||
self._stop_for_stale_execution(fence_error)
|
||||
return
|
||||
continue
|
||||
except WorkspaceError as e:
|
||||
self._stop_for_stale_execution(e)
|
||||
return
|
||||
except Exception as e:
|
||||
if self._shutdown_event.is_set():
|
||||
return # Shutdown requested, don't retry
|
||||
@@ -686,7 +748,11 @@ class RuntimeMCPSession:
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
await self._sleep_with_execution_fence(delay)
|
||||
except WorkspaceError as fence_error:
|
||||
self._stop_for_stale_execution(fence_error)
|
||||
return
|
||||
attempt += 1
|
||||
|
||||
@staticmethod
|
||||
@@ -769,6 +835,7 @@ class RuntimeMCPSession:
|
||||
|
||||
Returns True if reconnection succeeded within the timeout.
|
||||
"""
|
||||
await self._assert_execution_active()
|
||||
if self._shutdown_event.is_set():
|
||||
return False
|
||||
|
||||
@@ -779,6 +846,7 @@ class RuntimeMCPSession:
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(reconnected_event.wait(), timeout=self._RECONNECT_WAIT_TIMEOUT)
|
||||
await self._assert_execution_active()
|
||||
return self.status == MCPSessionStatus.CONNECTED
|
||||
except asyncio.TimeoutError:
|
||||
self.ap.logger.warning(f'MCP session {self.server_name} reconnect timed out')
|
||||
@@ -794,6 +862,7 @@ class RuntimeMCPSession:
|
||||
if not self.enable:
|
||||
return
|
||||
|
||||
await self._assert_execution_active()
|
||||
# Create background task for lifecycle management with retry
|
||||
self._lifecycle_task = asyncio.create_task(self._lifecycle_loop_with_retry())
|
||||
|
||||
@@ -805,11 +874,13 @@ class RuntimeMCPSession:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
raise Exception(f'Connection timeout after {startup_timeout} seconds')
|
||||
|
||||
await self._assert_execution_active()
|
||||
# Check for errors
|
||||
if self.status == MCPSessionStatus.ERROR:
|
||||
raise Exception('Connection failed, please check URL')
|
||||
|
||||
async def refresh(self):
|
||||
await self._assert_execution_active()
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
@@ -825,6 +896,7 @@ class RuntimeMCPSession:
|
||||
self.resource_capabilities = {}
|
||||
|
||||
tools = await self.session.list_tools()
|
||||
await self._assert_execution_active()
|
||||
|
||||
self.ap.logger.debug(f'Refresh MCP tools: {tools}')
|
||||
|
||||
@@ -846,34 +918,44 @@ class RuntimeMCPSession:
|
||||
)
|
||||
|
||||
await self._refresh_resources()
|
||||
await self._assert_execution_active()
|
||||
|
||||
async def _refresh_resources(self):
|
||||
await self._assert_execution_active()
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor: str | None = None
|
||||
for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES):
|
||||
await self._assert_execution_active()
|
||||
resources_result = await self.session.list_resources(cursor)
|
||||
await self._assert_execution_active()
|
||||
for resource in resources_result.resources:
|
||||
self.resources.append(_resource_to_dict(resource))
|
||||
cursor = getattr(resources_result, 'nextCursor', None)
|
||||
if not cursor:
|
||||
break
|
||||
self.ap.logger.debug(f'Refresh MCP resources: {len(self.resources)} resources found')
|
||||
except WorkspaceError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.ap.logger.debug(f'MCP server {self.server_name} does not support resources or failed to list: {e}')
|
||||
|
||||
try:
|
||||
cursor = None
|
||||
for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES):
|
||||
await self._assert_execution_active()
|
||||
templates_result = await self.session.list_resource_templates(cursor)
|
||||
await self._assert_execution_active()
|
||||
for template in templates_result.resourceTemplates:
|
||||
self.resource_templates.append(_resource_template_to_dict(template))
|
||||
cursor = getattr(templates_result, 'nextCursor', None)
|
||||
if not cursor:
|
||||
break
|
||||
self.ap.logger.debug(f'Refresh MCP resource templates: {len(self.resource_templates)} templates found')
|
||||
except WorkspaceError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.ap.logger.debug(
|
||||
f'MCP server {self.server_name} does not support resource templates or failed to list: {e}'
|
||||
@@ -992,17 +1074,15 @@ class RuntimeMCPSession:
|
||||
arguments: dict,
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> list[provider_message.ContentElement]:
|
||||
await self._assert_execution_active()
|
||||
for attempt in range(2):
|
||||
if not self.session:
|
||||
raise Exception('MCP session is not connected')
|
||||
|
||||
try:
|
||||
read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
|
||||
result = await self.session.call_tool(
|
||||
tool_name,
|
||||
arguments,
|
||||
read_timeout_seconds=read_timeout,
|
||||
)
|
||||
await self._assert_execution_active()
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
await self._assert_execution_active()
|
||||
except Exception as e:
|
||||
if self._is_tool_call_timeout(e):
|
||||
self.ap.logger.warning(
|
||||
@@ -1087,6 +1167,7 @@ class RuntimeMCPSession:
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> dict:
|
||||
"""Read a resource by URI with safety limits and audit metadata."""
|
||||
await self._assert_execution_active()
|
||||
if not self.session:
|
||||
raise Exception('MCP session is not connected')
|
||||
|
||||
@@ -1113,7 +1194,9 @@ class RuntimeMCPSession:
|
||||
if not self.session:
|
||||
raise Exception('MCP session is not connected')
|
||||
try:
|
||||
await self._assert_execution_active()
|
||||
result = await self.session.read_resource(AnyUrl(uri))
|
||||
await self._assert_execution_active()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 0 and self._is_session_terminated(e):
|
||||
@@ -1194,6 +1277,7 @@ class RuntimeMCPSession:
|
||||
'cache_hit': False,
|
||||
'warnings': warnings,
|
||||
}
|
||||
await self._assert_execution_active()
|
||||
self._resource_cache[cache_key] = {'cached_at': now, 'envelope': envelope}
|
||||
self._record_resource_read_trace(query, envelope)
|
||||
return envelope
|
||||
@@ -1228,7 +1312,11 @@ class RuntimeMCPSession:
|
||||
def get_runtime_info_dict(self) -> dict:
|
||||
info = {
|
||||
'status': self.status.value,
|
||||
'error_message': self.error_message,
|
||||
# Raw transport exceptions may echo command arguments, headers, or
|
||||
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
||||
# logs; resource-list responses expose only a stable status.
|
||||
'error_message': 'MCP runtime failed' if self.error_message else None,
|
||||
'error_code': 'runtime_error' if self.error_message else None,
|
||||
'error_phase': self.error_phase.value if self.error_phase else None,
|
||||
'retry_count': self.retry_count,
|
||||
'tool_count': len(self.get_tools()),
|
||||
@@ -1336,6 +1424,37 @@ class RuntimeMCPSession:
|
||||
await self._box_stdio_runtime.cleanup_session()
|
||||
|
||||
|
||||
def _execution_context_from_tenant(context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid:
|
||||
raise ValueError('MCP runtime requires an explicit instance UUID')
|
||||
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
||||
raise ValueError('MCP runtime requires a positive placement generation')
|
||||
return ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
)
|
||||
|
||||
|
||||
def _execution_context_from_query(query: pipeline_query.Query) -> ExecutionContext:
|
||||
return _execution_context_from_tenant(
|
||||
ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
placement_generation=getattr(query, 'placement_generation', 0) or 0,
|
||||
bot_uuid=getattr(query, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(query, 'query_uuid', None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# @loader.loader_class('mcp')
|
||||
class MCPLoader(loader.ToolLoader):
|
||||
"""MCP 工具加载器。
|
||||
@@ -1343,7 +1462,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
在此加载器中管理所有与 MCP Server 的连接。
|
||||
"""
|
||||
|
||||
sessions: dict[str, RuntimeMCPSession]
|
||||
sessions: dict[tuple[str, str, int, str], RuntimeMCPSession]
|
||||
|
||||
_last_listed_functions: list[resource_tool.LLMTool]
|
||||
|
||||
@@ -1355,6 +1474,21 @@ class MCPLoader(loader.ToolLoader):
|
||||
self._last_listed_functions = []
|
||||
self._hosted_mcp_tasks = []
|
||||
|
||||
async def _assert_execution_active(
|
||||
self,
|
||||
context: TenantContext,
|
||||
) -> ExecutionContext:
|
||||
"""Validate a caller's placement before accessing an MCP session."""
|
||||
|
||||
execution_context = _execution_context_from_tenant(context)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if binding.instance_uuid != execution_context.instance_uuid:
|
||||
raise WorkspaceInvariantError('MCP caller instance does not match the active Workspace binding')
|
||||
return execution_context
|
||||
|
||||
async def initialize(self):
|
||||
await self.load_mcp_servers_from_db()
|
||||
|
||||
@@ -1368,15 +1502,51 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
for server in servers:
|
||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Skipping MCP server {server.uuid}: Workspace execution binding is unavailable: {exc}'
|
||||
)
|
||||
continue
|
||||
|
||||
task = asyncio.create_task(self.host_mcp_server(config))
|
||||
task = asyncio.create_task(self.host_mcp_server(execution_context, config))
|
||||
self._hosted_mcp_tasks.append(task)
|
||||
|
||||
async def host_mcp_server(self, server_config: dict):
|
||||
@staticmethod
|
||||
def _scope_key(context: TenantContext) -> tuple[str, str, int]:
|
||||
execution_context = _execution_context_from_tenant(context)
|
||||
return (
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
execution_context.placement_generation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _session_key(cls, context: TenantContext, server_name: str) -> tuple[str, str, int, str]:
|
||||
return (*cls._scope_key(context), server_name)
|
||||
|
||||
def _sessions_for_context(self, context: TenantContext) -> list[RuntimeMCPSession]:
|
||||
scope_key = self._scope_key(context)
|
||||
return [session for key, session in self.sessions.items() if key[:3] == scope_key]
|
||||
|
||||
async def host_mcp_server(self, context: TenantContext, server_config: dict):
|
||||
execution_context = await self._assert_execution_active(context)
|
||||
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
||||
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
||||
raise ValueError('MCP server configuration belongs to another Workspace')
|
||||
server_config = dict(server_config)
|
||||
server_config['workspace_uuid'] = execution_context.workspace_uuid
|
||||
self.ap.logger.debug(f'Loading MCP server {server_config}')
|
||||
try:
|
||||
session = await self.load_mcp_server(server_config)
|
||||
self.sessions[server_config['name']] = session
|
||||
session = await self.load_mcp_server(execution_context, server_config)
|
||||
await self._assert_execution_active(execution_context)
|
||||
self.sessions[self._session_key(execution_context, server_config['name'])] = session
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
|
||||
@@ -1385,6 +1555,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
try:
|
||||
await self._assert_execution_active(execution_context)
|
||||
await session.start()
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
@@ -1394,7 +1565,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
|
||||
|
||||
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
|
||||
async def load_mcp_server(self, context: TenantContext, server_config: dict) -> RuntimeMCPSession:
|
||||
"""加载 MCP 服务器到运行时
|
||||
|
||||
Args:
|
||||
@@ -1404,6 +1575,13 @@ class MCPLoader(loader.ToolLoader):
|
||||
- enable: 是否启用
|
||||
- extra_args: 额外的配置参数 (可选)
|
||||
"""
|
||||
execution_context = await self._assert_execution_active(context)
|
||||
server_config = dict(server_config)
|
||||
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
||||
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
||||
raise ValueError('MCP server configuration belongs to another Workspace')
|
||||
server_config['workspace_uuid'] = execution_context.workspace_uuid
|
||||
|
||||
uuid_ = server_config.get('uuid')
|
||||
is_transient = False
|
||||
if not uuid_:
|
||||
@@ -1429,7 +1607,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
**extra_args,
|
||||
}
|
||||
|
||||
session = RuntimeMCPSession(name, mixed_config, enable, self.ap)
|
||||
session = RuntimeMCPSession(name, mixed_config, enable, self.ap, execution_context)
|
||||
|
||||
return session
|
||||
|
||||
@@ -1438,9 +1616,13 @@ class MCPLoader(loader.ToolLoader):
|
||||
v = getattr(query, 'variables', None) or {}
|
||||
return v.get('_pipeline_bound_mcp_servers', None)
|
||||
|
||||
def _eligible_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
|
||||
def _eligible_sessions_for_bound(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bound_mcp_servers: list[str] | None,
|
||||
) -> list[RuntimeMCPSession]:
|
||||
out: list[RuntimeMCPSession] = []
|
||||
for session in self.sessions.values():
|
||||
for session in self._sessions_for_context(context):
|
||||
if not session.enable:
|
||||
continue
|
||||
if session.status != MCPSessionStatus.CONNECTED:
|
||||
@@ -1452,10 +1634,14 @@ class MCPLoader(loader.ToolLoader):
|
||||
out.append(session)
|
||||
return out
|
||||
|
||||
def _eligible_resource_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
|
||||
def _eligible_resource_sessions_for_bound(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bound_mcp_servers: list[str] | None,
|
||||
) -> list[RuntimeMCPSession]:
|
||||
return [
|
||||
session
|
||||
for session in self._eligible_sessions_for_bound(bound_mcp_servers)
|
||||
for session in self._eligible_sessions_for_bound(context, bound_mcp_servers)
|
||||
if session.has_resource_support()
|
||||
]
|
||||
|
||||
@@ -1486,12 +1672,13 @@ class MCPLoader(loader.ToolLoader):
|
||||
]
|
||||
|
||||
async def _invoke_mcp_list_resources(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
execution_context = _execution_context_from_query(query)
|
||||
server_name = parameters.get('server_name') if parameters else None
|
||||
if not server_name or not isinstance(server_name, str):
|
||||
return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')]
|
||||
|
||||
bound = self._get_bound_mcp_from_query(query)
|
||||
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)}
|
||||
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(execution_context, bound)}
|
||||
if server_name not in allowed:
|
||||
return [
|
||||
provider_message.ContentElement.from_text(
|
||||
@@ -1501,7 +1688,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
)
|
||||
]
|
||||
|
||||
session = self.get_session(server_name)
|
||||
session = self.get_session(execution_context, server_name)
|
||||
if session is None or session.status != MCPSessionStatus.CONNECTED:
|
||||
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
|
||||
|
||||
@@ -1518,6 +1705,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
return [provider_message.ContentElement.from_text(json.dumps(body, ensure_ascii=False, indent=2))]
|
||||
|
||||
async def _invoke_mcp_read_resource(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
execution_context = _execution_context_from_query(query)
|
||||
server_name = parameters.get('server_name') if parameters else None
|
||||
uri = parameters.get('uri') if parameters else None
|
||||
if not server_name or not isinstance(server_name, str):
|
||||
@@ -1526,7 +1714,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
return [provider_message.ContentElement.from_text('Error: "uri" (string) is required.')]
|
||||
|
||||
bound = self._get_bound_mcp_from_query(query)
|
||||
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)}
|
||||
allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(execution_context, bound)}
|
||||
if server_name not in allowed:
|
||||
return [
|
||||
provider_message.ContentElement.from_text(
|
||||
@@ -1535,7 +1723,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
)
|
||||
]
|
||||
|
||||
session = self.get_session(server_name)
|
||||
session = self.get_session(execution_context, server_name)
|
||||
if session is None or session.status != MCPSessionStatus.CONNECTED:
|
||||
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
|
||||
|
||||
@@ -1586,13 +1774,15 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
async def get_tools(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
*,
|
||||
include_resource_tools: bool = True,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
await self._assert_execution_active(context)
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
for session in self.sessions.values():
|
||||
for session in self._sessions_for_context(context):
|
||||
# If bound_mcp_servers is specified, only include tools from those servers
|
||||
if bound_mcp_servers is not None:
|
||||
if session.server_uuid in bound_mcp_servers:
|
||||
@@ -1601,7 +1791,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
# If no bound servers specified, include all tools
|
||||
all_functions.extend(session.get_tools())
|
||||
|
||||
if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers):
|
||||
if include_resource_tools and self._eligible_resource_sessions_for_bound(context, bound_mcp_servers):
|
||||
all_functions.extend(self._mcp_synthetic_resource_tools())
|
||||
|
||||
self._last_listed_functions = all_functions
|
||||
@@ -1610,13 +1800,15 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
async def get_tool_catalog(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
*,
|
||||
include_resource_tools: bool = False,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
await self._assert_execution_active(context)
|
||||
items: list[dict[str, typing.Any]] = []
|
||||
|
||||
for session in self.sessions.values():
|
||||
for session in self._sessions_for_context(context):
|
||||
if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers:
|
||||
continue
|
||||
for tool in session.get_tools():
|
||||
@@ -1632,7 +1824,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
}
|
||||
)
|
||||
|
||||
if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers):
|
||||
if include_resource_tools and self._eligible_resource_sessions_for_bound(context, bound_mcp_servers):
|
||||
for tool in self._mcp_synthetic_resource_tools():
|
||||
items.append(
|
||||
{
|
||||
@@ -1648,18 +1840,20 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
return items
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
async def has_tool(self, context: TenantContext, name: str) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
await self._assert_execution_active(context)
|
||||
if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
|
||||
return bool(self._eligible_resource_sessions_for_bound(None))
|
||||
for session in self.sessions.values():
|
||||
return bool(self._eligible_resource_sessions_for_bound(context, None))
|
||||
for session in self._sessions_for_context(context):
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_tool(self, name: str) -> resource_tool.LLMTool | None:
|
||||
for session in self.sessions.values():
|
||||
async def get_tool(self, context: TenantContext, name: str) -> resource_tool.LLMTool | None:
|
||||
await self._assert_execution_active(context)
|
||||
for session in self._sessions_for_context(context):
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
return function
|
||||
@@ -1667,6 +1861,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
"""执行工具调用"""
|
||||
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
|
||||
if name == MCP_TOOL_LIST_RESOURCES:
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
|
||||
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
|
||||
@@ -1676,7 +1871,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
|
||||
return await self._invoke_mcp_read_resource(parameters, query)
|
||||
|
||||
for session in self.sessions.values():
|
||||
for session in self._sessions_for_context(execution_context):
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')
|
||||
@@ -1690,22 +1885,25 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
raise ValueError(f'Tool not found: {name}')
|
||||
|
||||
async def get_resources(self, server_name: str) -> list[dict]:
|
||||
async def get_resources(self, context: TenantContext, server_name: str) -> list[dict]:
|
||||
"""Get resources from a specific MCP server."""
|
||||
session = self.get_session(server_name)
|
||||
await self._assert_execution_active(context)
|
||||
session = self.get_session(context, server_name)
|
||||
if session is None:
|
||||
raise ValueError(f'MCP server not found: {server_name}')
|
||||
return session.get_resources()
|
||||
|
||||
async def get_resource_templates(self, server_name: str) -> list[dict]:
|
||||
async def get_resource_templates(self, context: TenantContext, server_name: str) -> list[dict]:
|
||||
"""Get resource templates from a specific MCP server."""
|
||||
session = self.get_session(server_name)
|
||||
await self._assert_execution_active(context)
|
||||
session = self.get_session(context, server_name)
|
||||
if session is None:
|
||||
raise ValueError(f'MCP server not found: {server_name}')
|
||||
return session.get_resource_templates()
|
||||
|
||||
async def read_resource_envelope(
|
||||
self,
|
||||
context: TenantContext,
|
||||
server_name: str,
|
||||
uri: str,
|
||||
*,
|
||||
@@ -1716,7 +1914,8 @@ class MCPLoader(loader.ToolLoader):
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> dict:
|
||||
"""Read a resource from a specific MCP server and return metadata plus contents."""
|
||||
session = self.get_session(server_name)
|
||||
await self._assert_execution_active(context)
|
||||
session = self.get_session(context, server_name)
|
||||
if session is None:
|
||||
raise ValueError(f'MCP server not found: {server_name}')
|
||||
return await session.read_resource_envelope(
|
||||
@@ -1728,24 +1927,28 @@ class MCPLoader(loader.ToolLoader):
|
||||
query=query,
|
||||
)
|
||||
|
||||
async def read_resource(self, server_name: str, uri: str) -> list[dict]:
|
||||
async def read_resource(self, context: TenantContext, server_name: str, uri: str) -> list[dict]:
|
||||
"""Read a resource from a specific MCP server."""
|
||||
envelope = await self.read_resource_envelope(server_name, uri)
|
||||
envelope = await self.read_resource_envelope(context, server_name, uri)
|
||||
return envelope['contents']
|
||||
|
||||
def get_session_by_uuid(self, server_uuid: str) -> RuntimeMCPSession | None:
|
||||
for session in self.sessions.values():
|
||||
def get_session_by_uuid(self, context: TenantContext, server_uuid: str) -> RuntimeMCPSession | None:
|
||||
for session in self._sessions_for_context(context):
|
||||
if session.server_uuid == server_uuid:
|
||||
return session
|
||||
return None
|
||||
|
||||
def _resolve_attachment_session(self, attachment: dict) -> RuntimeMCPSession | None:
|
||||
def _resolve_attachment_session(
|
||||
self,
|
||||
context: TenantContext,
|
||||
attachment: dict,
|
||||
) -> RuntimeMCPSession | None:
|
||||
server_uuid = attachment.get('server_uuid') or attachment.get('server_id')
|
||||
server_name = attachment.get('server_name')
|
||||
if server_uuid:
|
||||
return self.get_session_by_uuid(server_uuid)
|
||||
return self.get_session_by_uuid(context, server_uuid)
|
||||
if server_name:
|
||||
return self.get_session(server_name)
|
||||
return self.get_session(context, server_name)
|
||||
return None
|
||||
|
||||
async def build_resource_context_for_query(
|
||||
@@ -1756,6 +1959,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES,
|
||||
) -> str:
|
||||
"""Build host-controlled MCP resource context for the current query."""
|
||||
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
|
||||
return ''
|
||||
|
||||
@@ -1764,7 +1968,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
return ''
|
||||
|
||||
bound = self._get_bound_mcp_from_query(query)
|
||||
eligible = self._eligible_resource_sessions_for_bound(bound)
|
||||
eligible = self._eligible_resource_sessions_for_bound(execution_context, bound)
|
||||
eligible_by_uuid = {session.server_uuid: session for session in eligible}
|
||||
eligible_by_name = {session.server_name: session for session in eligible}
|
||||
|
||||
@@ -1772,6 +1976,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
remaining_tokens = default_max_tokens
|
||||
|
||||
for raw_attachment in attachments:
|
||||
await self._assert_execution_active(execution_context)
|
||||
if remaining_tokens <= 0:
|
||||
break
|
||||
if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False:
|
||||
@@ -1786,7 +1991,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
if not uri or not isinstance(uri, str):
|
||||
continue
|
||||
|
||||
session = self._resolve_attachment_session(attachment)
|
||||
session = self._resolve_attachment_session(execution_context, attachment)
|
||||
if session is None:
|
||||
continue
|
||||
if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name:
|
||||
@@ -1804,6 +2009,8 @@ class MCPLoader(loader.ToolLoader):
|
||||
source='preloaded',
|
||||
query=query,
|
||||
)
|
||||
except WorkspaceError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to preload MCP resource {uri!r} from {session.server_name!r}: {e}')
|
||||
continue
|
||||
@@ -1843,37 +2050,40 @@ class MCPLoader(loader.ToolLoader):
|
||||
pass
|
||||
return context
|
||||
|
||||
async def remove_mcp_server(self, server_name: str):
|
||||
async def remove_mcp_server(self, context: TenantContext, server_name: str):
|
||||
"""移除 MCP 服务器"""
|
||||
if server_name not in self.sessions:
|
||||
await self._assert_execution_active(context)
|
||||
key = self._session_key(context, server_name)
|
||||
if key not in self.sessions:
|
||||
self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')
|
||||
return
|
||||
|
||||
session = self.sessions.pop(server_name)
|
||||
session = self.sessions.pop(key)
|
||||
await session.shutdown()
|
||||
self.ap.logger.info(f'Removed MCP server: {server_name}')
|
||||
|
||||
def get_session(self, server_name: str) -> RuntimeMCPSession | None:
|
||||
def get_session(self, context: TenantContext, server_name: str) -> RuntimeMCPSession | None:
|
||||
"""获取指定名称的 MCP 会话"""
|
||||
return self.sessions.get(server_name)
|
||||
return self.sessions.get(self._session_key(context, server_name))
|
||||
|
||||
def has_session(self, server_name: str) -> bool:
|
||||
def has_session(self, context: TenantContext, server_name: str) -> bool:
|
||||
"""检查是否存在指定名称的 MCP 会话"""
|
||||
return server_name in self.sessions
|
||||
return self._session_key(context, server_name) in self.sessions
|
||||
|
||||
def get_all_server_names(self) -> list[str]:
|
||||
def get_all_server_names(self, context: TenantContext) -> list[str]:
|
||||
"""获取所有已加载的 MCP 服务器名称"""
|
||||
return list(self.sessions.keys())
|
||||
return [session.server_name for session in self._sessions_for_context(context)]
|
||||
|
||||
def get_server_tool_count(self, server_name: str) -> int:
|
||||
def get_server_tool_count(self, context: TenantContext, server_name: str) -> int:
|
||||
"""获取指定服务器的工具数量"""
|
||||
session = self.get_session(server_name)
|
||||
session = self.get_session(context, server_name)
|
||||
return len(session.get_tools()) if session else 0
|
||||
|
||||
def get_all_servers_info(self) -> dict[str, dict]:
|
||||
def get_all_servers_info(self, context: TenantContext) -> dict[str, dict]:
|
||||
"""获取所有服务器的信息"""
|
||||
info = {}
|
||||
for server_name, session in self.sessions.items():
|
||||
for session in self._sessions_for_context(context):
|
||||
server_name = session.server_name
|
||||
tools = session.get_tools()
|
||||
info[server_name] = {
|
||||
'name': server_name,
|
||||
@@ -1887,23 +2097,13 @@ class MCPLoader(loader.ToolLoader):
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
self.ap.logger.info('Shutting down all MCP sessions...')
|
||||
|
||||
hosted_tasks = [task for task in self._hosted_mcp_tasks if not task.done()]
|
||||
for task in hosted_tasks:
|
||||
task.cancel()
|
||||
if hosted_tasks:
|
||||
await asyncio.gather(*hosted_tasks, return_exceptions=True)
|
||||
self._hosted_mcp_tasks.clear()
|
||||
|
||||
async def shutdown_session(server_name: str, session: RuntimeMCPSession) -> None:
|
||||
for key, session in list(self.sessions.items()):
|
||||
try:
|
||||
await session.shutdown()
|
||||
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
|
||||
self.ap.logger.debug(f'Shutdown MCP session: {session.server_name}')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
|
||||
|
||||
await asyncio.gather(
|
||||
*(shutdown_session(server_name, session) for server_name, session in list(self.sessions.items()))
|
||||
)
|
||||
self.ap.logger.error(
|
||||
f'Error shutting down MCP session {session.server_name}: {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
self.sessions.clear()
|
||||
self.ap.logger.info('All MCP sessions shutdown complete')
|
||||
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
import shutil
|
||||
import shlex
|
||||
import threading
|
||||
from contextlib import suppress, AsyncExitStack
|
||||
from contextlib import suppress, AsyncExitStack, asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pydantic
|
||||
@@ -94,6 +94,60 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
||||
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def authenticated_websocket_client(url: str, headers: dict[str, str]):
|
||||
"""MCP WebSocket transport with host-only Box relay headers.
|
||||
|
||||
The upstream MCP helper does not expose WebSocket handshake headers. This
|
||||
mirrors that transport while keeping the Box control token out of the URL,
|
||||
JSON-RPC payloads, and logs.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import anyio
|
||||
import mcp.types as mcp_types
|
||||
from mcp.shared.message import SessionMessage
|
||||
from pydantic import ValidationError
|
||||
from websockets.asyncio.client import connect as ws_connect
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
|
||||
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
|
||||
|
||||
async with ws_connect(
|
||||
url,
|
||||
subprotocols=[Subprotocol('mcp')],
|
||||
additional_headers=dict(headers),
|
||||
proxy=None,
|
||||
) as websocket:
|
||||
|
||||
async def ws_reader():
|
||||
async with read_stream_writer:
|
||||
async for raw_text in websocket:
|
||||
try:
|
||||
message = mcp_types.JSONRPCMessage.model_validate_json(raw_text)
|
||||
await read_stream_writer.send(SessionMessage(message))
|
||||
except ValidationError as exc: # pragma: no cover - upstream parity
|
||||
await read_stream_writer.send(exc)
|
||||
|
||||
async def ws_writer():
|
||||
async with write_stream_reader:
|
||||
async for session_message in write_stream_reader:
|
||||
payload = session_message.message.model_dump(
|
||||
by_alias=True,
|
||||
mode='json',
|
||||
exclude_none=True,
|
||||
)
|
||||
await websocket.send(json.dumps(payload))
|
||||
|
||||
async with anyio.create_task_group() as task_group:
|
||||
task_group.start_soon(ws_reader)
|
||||
task_group.start_soon(ws_writer)
|
||||
yield read_stream, write_stream
|
||||
task_group.cancel_scope.cancel()
|
||||
|
||||
|
||||
class _TransferredStack:
|
||||
"""Adapts an already-populated AsyncExitStack into an async context manager
|
||||
so ownership of its resources can be transferred into another exit stack.
|
||||
@@ -149,6 +203,7 @@ class BoxStdioSessionRuntime:
|
||||
resolved_host_path = self.resolve_host_path() if host_path is ... else host_path
|
||||
return BoxWorkspaceSession(
|
||||
self.ap.box_service,
|
||||
self.owner.execution_context,
|
||||
self.owner._build_box_session_id(),
|
||||
host_path=resolved_host_path,
|
||||
host_path_mode=self.config.host_path_mode,
|
||||
@@ -249,7 +304,11 @@ class BoxStdioSessionRuntime:
|
||||
if install_cmd:
|
||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||
payload['process_id'] = self.process_id
|
||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||
await workspace.box_service.start_managed_process(
|
||||
workspace.execution_context,
|
||||
workspace.session_id,
|
||||
payload,
|
||||
)
|
||||
except Exception:
|
||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||
raise
|
||||
@@ -259,7 +318,10 @@ class BoxStdioSessionRuntime:
|
||||
f'process_id={self.process_id} (transport reconnect)'
|
||||
)
|
||||
|
||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
||||
(
|
||||
websocket_url,
|
||||
websocket_headers,
|
||||
) = await workspace.get_managed_process_websocket_connection(self.process_id)
|
||||
|
||||
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
|
||||
# in the same task as the serve loop that follows. websocket_client and
|
||||
@@ -277,7 +339,12 @@ class BoxStdioSessionRuntime:
|
||||
# attempt re-attaches to the same live process; once it has finished
|
||||
# cold start the handshake succeeds and stays healthy.
|
||||
try:
|
||||
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
||||
transport_context = (
|
||||
authenticated_websocket_client(websocket_url, websocket_headers)
|
||||
if websocket_headers
|
||||
else websocket_client(websocket_url)
|
||||
)
|
||||
transport = await self.owner.exit_stack.enter_async_context(transport_context)
|
||||
read_stream, write_stream = transport
|
||||
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
|
||||
@@ -11,6 +11,7 @@ from .. import loader
|
||||
from ..errors import ToolNotFoundError
|
||||
from .availability import is_box_backend_available
|
||||
from . import skill as skill_loader
|
||||
from ....api.http.context import ExecutionContext
|
||||
|
||||
EXEC_TOOL_NAME = 'exec'
|
||||
READ_TOOL_NAME = 'read'
|
||||
@@ -56,6 +57,17 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
@staticmethod
|
||||
def _execution_context(query: pipeline_query.Query) -> ExecutionContext:
|
||||
return ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
placement_generation=getattr(query, 'placement_generation', 0) or 0,
|
||||
bot_uuid=getattr(query, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(query, 'query_uuid', None),
|
||||
)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not await self._is_sandbox_available():
|
||||
return []
|
||||
@@ -142,7 +154,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
result = self._normalize_exec_result(result)
|
||||
|
||||
if selected_skill is not None:
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
return result
|
||||
|
||||
def _resolve_host_path(
|
||||
@@ -162,7 +174,11 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
)
|
||||
|
||||
box_service = self.ap.box_service
|
||||
host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace
|
||||
host_root = (
|
||||
selected_skill.get('package_root')
|
||||
if selected_skill is not None
|
||||
else box_service._tenant_workspace(self._execution_context(query))
|
||||
)
|
||||
if not host_root:
|
||||
raise ValueError('No host workspace configured for file operations.')
|
||||
|
||||
@@ -522,11 +538,19 @@ else:
|
||||
return self._read_text_file_preview(host_path, parameters)
|
||||
|
||||
try:
|
||||
result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
|
||||
result = await self.ap.box_service.read_skill_file(
|
||||
self._execution_context(query),
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
)
|
||||
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
|
||||
except Exception:
|
||||
try:
|
||||
result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative)
|
||||
result = await self.ap.box_service.list_skill_files(
|
||||
self._execution_context(query),
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
)
|
||||
entries = [entry['name'] for entry in result.get('entries', [])]
|
||||
return self._build_directory_result(entries)
|
||||
except Exception as exc:
|
||||
@@ -562,8 +586,9 @@ else:
|
||||
if encoding != 'text':
|
||||
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
|
||||
selected_skill, relative = skill_request
|
||||
await self.ap.box_service.write_skill_file(selected_skill['name'], relative, content)
|
||||
await self.ap.skill_mgr.reload_skills()
|
||||
execution_context = self._execution_context(query)
|
||||
await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
|
||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
@@ -579,7 +604,7 @@ else:
|
||||
self._write_host_file(host_path, content, parameters)
|
||||
except ValueError as exc:
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
@@ -603,7 +628,11 @@ else:
|
||||
):
|
||||
selected_skill, relative = skill_request
|
||||
try:
|
||||
result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
|
||||
result = await self.ap.box_service.read_skill_file(
|
||||
self._execution_context(query),
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
)
|
||||
except Exception:
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
content = result.get('content', '')
|
||||
@@ -613,8 +642,14 @@ else:
|
||||
if count > 1:
|
||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
await self.ap.box_service.write_skill_file(selected_skill['name'], relative, new_content)
|
||||
await self.ap.skill_mgr.reload_skills()
|
||||
execution_context = self._execution_context(query)
|
||||
await self.ap.box_service.write_skill_file(
|
||||
execution_context,
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
new_content,
|
||||
)
|
||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
@@ -637,10 +672,10 @@ else:
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None:
|
||||
def _refresh_skill_from_disk(self, query: pipeline_query.Query, selected_skill: dict | None) -> None:
|
||||
if selected_skill is None:
|
||||
return
|
||||
|
||||
@@ -650,7 +685,7 @@ else:
|
||||
|
||||
refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
|
||||
if callable(refresh_skill):
|
||||
refresh_skill(selected_skill.get('name', ''))
|
||||
refresh_skill(self._execution_context(query), selected_skill.get('name', ''))
|
||||
|
||||
async def _is_sandbox_available(self) -> bool:
|
||||
"""Refresh backend availability so Box reconnects restore tool exposure."""
|
||||
|
||||
@@ -67,7 +67,11 @@ class PluginToolLoader(loader.ToolLoader):
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
try:
|
||||
return await self.ap.plugin_connector.call_tool(
|
||||
name, parameters, session=query.session, query_id=query.query_id
|
||||
name,
|
||||
parameters,
|
||||
session=query.session,
|
||||
query_id=query.query_id,
|
||||
query_uuid=query.query_uuid,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
|
||||
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
import typing
|
||||
|
||||
from ....box import workspace as box_workspace
|
||||
from ....api.http.context import ExecutionContext
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core import app
|
||||
@@ -36,7 +37,15 @@ def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict
|
||||
if skill_mgr is None:
|
||||
return {}
|
||||
|
||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
placement_generation=getattr(query, 'placement_generation', 0) or 0,
|
||||
bot_uuid=getattr(query, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(query, 'query_uuid', None),
|
||||
)
|
||||
visible_skills = skill_mgr.get_skills(execution_context)
|
||||
bound_skills = get_bound_skill_names(query)
|
||||
if bound_skills is None:
|
||||
return visible_skills
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user