From 82486225a614d8881566fbd006633bc0f8d86460 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:12:42 +0800 Subject: [PATCH] refactor(api): route management and runtime consumers through application APIs --- ARCHITECTURE.md | 8 +- dev-docs/architecture/application-api.md | 25 +- skills/skills/langbot-mcp-ops/SKILL.md | 5 +- src/langbot/pkg/api/application/api.py | 4 + src/langbot/pkg/api/application/auxiliary.py | 41 +- src/langbot/pkg/api/application/execution.py | 16 +- .../pkg/api/application/plugin_operations.py | 11 +- .../pkg/api/application/service_operations.py | 25 +- src/langbot/pkg/api/application/transport.py | 45 ++ src/langbot/pkg/api/http/controller/group.py | 38 + .../controller/groups/agent_debug_stream.py | 26 +- .../pkg/api/http/controller/groups/agents.py | 39 +- .../pkg/api/http/controller/groups/apikeys.py | 26 +- .../api/http/controller/groups/assistant.py | 86 ++- .../pkg/api/http/controller/groups/box.py | 8 +- .../api/http/controller/groups/extensions.py | 18 +- .../pkg/api/http/controller/groups/files.py | 48 +- .../http/controller/groups/knowledge/base.py | 56 +- .../controller/groups/knowledge/engines.py | 10 +- .../controller/groups/knowledge/parsers.py | 2 +- .../api/http/controller/groups/monitoring.py | 452 +++++++----- .../controller/groups/pipelines/migration.py | 10 +- .../controller/groups/pipelines/pipelines.py | 111 ++- .../groups/pipelines/websocket_chat.py | 80 +- .../controller/groups/platform/adapters.py | 97 +-- .../http/controller/groups/platform/bots.py | 83 ++- .../pkg/api/http/controller/groups/plugins.py | 687 ++++-------------- .../http/controller/groups/provider/models.py | 120 ++- .../controller/groups/provider/providers.py | 67 +- .../controller/groups/provider/requesters.py | 21 +- .../http/controller/groups/resources/mcp.py | 54 +- .../http/controller/groups/resources/tools.py | 28 +- .../pkg/api/http/controller/groups/skills.py | 56 +- .../pkg/api/http/controller/groups/stats.py | 27 +- .../pkg/api/http/controller/groups/system.py | 188 +---- .../http/controller/groups/webhook_mgmt.py | 39 +- .../api/http/controller/groups/workspaces.py | 260 ++----- .../pkg/api/http/service/assistant_tools.py | 64 +- src/langbot/pkg/api/http/service/bot.py | 4 +- src/langbot/pkg/api/http/service/pipeline.py | 9 +- src/langbot/pkg/api/mcp/server.py | 290 +++++--- src/langbot/pkg/operation_trace/routes.py | 77 +- src/langbot/pkg/plugin/handler.py | 74 ++ tests/integration/api/test_bots.py | 51 +- tests/integration/api/test_box_security.py | 5 + .../api/test_fresh_oss_workspace_journey.py | 3 + tests/integration/api/test_knowledge.py | 57 +- tests/integration/api/test_monitoring.py | 23 + tests/integration/api/test_pipelines.py | 51 +- .../integration/api/test_plugins_security.py | 50 +- tests/integration/api/test_providers.py | 62 +- .../api/test_skills_entitlements.py | 6 + tests/integration/api/test_workspaces.py | 92 +++ .../persistence/test_migrations_postgres.py | 3 + .../agent/test_history_event_api_auth.py | 17 +- .../agent/test_run_ledger_api_auth.py | 18 +- tests/unit_tests/agent/test_state_api_auth.py | 19 +- .../api/service/test_cli_diagnostics.py | 3 + .../api/service/test_provider_cascade.py | 8 +- .../service/test_runner_extension_boundary.py | 4 + .../api/test_adapter_session_scoping.py | 3 + .../unit_tests/api/test_agents_controller.py | 24 +- .../api/test_application_plugins.py | 22 +- .../api/test_application_workspace_streams.py | 6 +- .../api/test_bot_controller_secrets.py | 8 +- .../api/test_bots_controller_errors.py | 23 + .../api/test_execution_application_api.py | 140 +++- .../api/test_extensions_runtime_fence.py | 9 + .../api/test_file_upload_scoping.py | 3 + .../api/test_management_assistant.py | 122 +++- tests/unit_tests/api/test_mcp_controller.py | 11 +- tests/unit_tests/api/test_mcp_server.py | 58 +- .../api/test_pipeline_migration_routes.py | 11 +- .../api/test_pipelines_controller.py | 17 +- .../api/test_plugin_runtime_route_fence.py | 12 +- .../api/test_provider_controller_secrets.py | 10 +- .../api/test_resource_secret_permissions.py | 20 +- tests/unit_tests/api/test_slack_setup.py | 59 ++ tests/unit_tests/api/test_stats_controller.py | 3 + tests/unit_tests/api/test_tools_controller.py | 3 + tests/unit_tests/plugin/test_handler.py | 2 +- .../unit_tests/plugin/test_handler_actions.py | 26 +- .../unit_tests/plugin/test_handler_tenancy.py | 20 +- .../plugin/test_runner_reasoning_override.py | 7 +- .../unit_tests/provider/test_codex_errors.py | 17 +- tests/utils/application_api.py | 32 + 86 files changed, 2532 insertions(+), 2043 deletions(-) create mode 100644 src/langbot/pkg/api/application/transport.py create mode 100644 tests/utils/application_api.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 87fe5c9b0..a0fb44192 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,13 +92,14 @@ The HTTP and MCP surfaces are parallel entrypoints into the same service layer: ```text HTTP client / Web UI → Quart route group + → ApplicationAPI (Workspace operations) → api/http/service/* → Application managers / persistence / runtime connectors MCP client → /mcp mount → api/mcp/server.py tools - → the same service layer directly + → ApplicationAPI → the same service layer ``` ## Message Flow @@ -259,8 +260,9 @@ marked for removal in the next major version. Start LangBot with ## HTTP API, Web UI, and MCP Server The [Application API foundation](dev-docs/architecture/application-api.md) provides -versioned Workspace and execution-context operations for future consumer adoption. -Existing HTTP/MCP/assistant entrypoints retain their current service paths. +versioned Workspace operations used by HTTP, MCP and management-assistant tools. +Runtime business actions use its ExecutionAPI before the secured Host dispatcher. +Authentication, public ingress and instance administration retain their own boundaries. `pkg/api/http/controller/main.py` builds a Quart app, registers route groups, serves the built SPA, and wraps the ASGI app with the MCP dispatcher. diff --git a/dev-docs/architecture/application-api.md b/dev-docs/architecture/application-api.md index 3eef912e2..d303b297a 100644 --- a/dev-docs/architecture/application-api.md +++ b/dev-docs/architecture/application-api.md @@ -2,9 +2,10 @@ The application package provides a callable business boundary inside Core. A consumer supplies a trusted identity and operation arguments, then receives a -validated result or a stable application error. HTTP, MCP, the management -assistant and `lbctl` retain their existing entrypoints in this change; their -adoption of this boundary belongs in separate changes. +validated result or a stable application error. Workspace HTTP routes, MCP tools +and management-assistant tools invoke this boundary. Their external paths, +response projections and authentication remain owned by each transport; `lbctl` +continues to use the same HTTP interface. ## Workspace operations @@ -109,10 +110,20 @@ messages remain with their owning boundaries. Composite HTTP response layouts (such as a combined monitoring page) are consumer projections of these operations. This does not add new platform APIs or new instance-level powers. -Existing routes do not call the new application dispatcher. No SDK protocol or `lbctl` command is -changed by this foundation. +Workspace management consumers call the application dispatcher explicitly. HTTP +keeps its established business-error responses and rich request audit; the +application dispatcher does not duplicate that audit. Background assistant tools +record their own operations after the initiating HTTP request has finished. + +Runtime business actions invoke `ExecutionAPI` before reaching the secured Host +action table. The transport separates trusted caller/run identity from business +arguments and preserves SDK response frames, structured errors and cancellation. +Internal handshake and stream-control messages retain their own boundary. No SDK +wire protocol or `lbctl` command changes are required. Consumer adoption should verify the same operation through each selected transport, including denied access, missing resources, invalid inputs, -cancellation and secret handling. The existing Core smoke exercises current -consumers; it does not by itself prove a new consumer integration. +cancellation and secret handling. The Core smoke exercises the HTTP and Runtime +protocols. Direct application tests also verify validation, isolation and business +effects; consumer regression tests retain the established HTTP/MCP response and +streaming contracts. diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index a9d723de3..b1d3d73b8 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -159,8 +159,9 @@ state as well; it is not the same operation as disconnecting an account. ## Implementation & maintenance (for LangBot developers) -- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service - layer directly, so the MCP surface stays aligned with the API. +- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Workspace tools invoke + the same Application API operations as HTTP, with shared validation, permissions, + Workspace binding and audit. Tool names, arguments and result shapes stay stable. - Mount: `src/langbot/pkg/api/mcp/mount.py` — an ASGI dispatcher fronting Quart, authenticating `/mcp` requests, running the streamable-HTTP session manager. - Smoke test: `tests/manual/mcp_smoke.py`. diff --git a/src/langbot/pkg/api/application/api.py b/src/langbot/pkg/api/application/api.py index 37f81253f..ceb796bfa 100644 --- a/src/langbot/pkg/api/application/api.py +++ b/src/langbot/pkg/api/application/api.py @@ -168,6 +168,10 @@ class ApplicationAPI: async def _record(self, spec, context, observed_at, payload=None, *, outcome='ok', status=200, started=False): from ..context import PrincipalContext, WorkspaceContext + from .transport import transport_records_audit + + if transport_records_audit.get(): + return if not ( isinstance(context, RequestContext) diff --git a/src/langbot/pkg/api/application/auxiliary.py b/src/langbot/pkg/api/application/auxiliary.py index d5228f1cd..e7222d1ea 100644 --- a/src/langbot/pkg/api/application/auxiliary.py +++ b/src/langbot/pkg/api/application/auxiliary.py @@ -13,7 +13,7 @@ import sqlalchemy from pydantic import Field from .contracts import Input, Operation -from ..authz import Permission, PermissionDeniedError +from ..authz import AuthenticationDeniedError, Permission, PermissionDeniedError from ..context import ExecutionContext, PrincipalType from ..secrets import redact_secrets from ...entity.persistence.metadata import WorkspaceMetadata @@ -38,12 +38,12 @@ class Member(Input): class MemberRole(Member): - role: Literal['admin', 'developer', 'operator', 'viewer'] + role: str class Invite(Input): email: str - role: Literal['admin', 'developer', 'operator', 'viewer'] = 'viewer' + role: str = 'viewer' class Invitation(Input): @@ -120,7 +120,9 @@ class WizardProgress(Input): selected_scenario: str | None = None selected_adapter: str | None = None created_bot_uuid: str | None = None + created_pipeline_uuid: str | None = None bot_saved: bool = False + message_received: bool = False selected_runner: str | None = None @@ -184,7 +186,21 @@ async def workspace_current(ap, context, payload): if context.principal.principal_type == PrincipalType.ACCOUNT: member = await _actor(ap, context) account = await ap.user_service.get_user_by_uuid(context.account_uuid) + if account is None: + raise AuthenticationDeniedError('Account not found') result['membership'] = _membership(member, display_name=account.user, email=account.normalized_email) + elif context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: + result['membership'] = { + 'uuid': None, + 'workspace_uuid': context.workspace_uuid, + 'account_uuid': None, + 'display_name': None, + 'email': None, + 'role': 'owner', + 'status': 'active', + 'joined_at': None, + 'created_at': None, + } resolver = getattr(ap, 'entitlement_resolver', None) if workspace.source == 'cloud_projection' and resolver is not None: from ..http.service.workspace_limits import LIMIT_KEYS @@ -222,6 +238,10 @@ async def members_list(ap, context, payload): async def _record_change(ap, context, action, changes, resource_id=None): + from .transport import transport_records_audit + + if transport_records_audit.get(): + return service = ap.workspace_settings_service rule = tracing.ACTION_RULES_BY_ACTION[action] await service.record( @@ -529,7 +549,9 @@ async def wizard_complete(ap, context, payload): async def wizard_progress(ap, context, payload): - await _metadata_set(ap, context, 'wizard_progress', json.dumps(payload.model_dump(), ensure_ascii=False)) + await _metadata_set( + ap, context, 'wizard_progress', json.dumps(payload.model_dump(exclude_unset=True), ensure_ascii=False) + ) return None @@ -582,16 +604,17 @@ async def storage_analysis(ap, context, payload): async def wizard_get(ap, context, payload): result = await ap.persistence_mgr.execute_async( - sqlalchemy.select(WorkspaceMetadata).where( + sqlalchemy.select(WorkspaceMetadata.key, WorkspaceMetadata.value).where( WorkspaceMetadata.workspace_uuid == context.workspace_uuid, WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']), ) ) values = {row.key: row.value for row in result.all()} - return { - 'status': values.get('wizard_status'), - 'progress': json.loads(values['wizard_progress']) if values.get('wizard_progress') else None, - } + try: + progress = json.loads(values['wizard_progress']) if values.get('wizard_progress') else None + except (json.JSONDecodeError, TypeError): + progress = None + return {'status': values.get('wizard_status'), 'progress': progress} async def plugin_status(ap, context, payload): diff --git a/src/langbot/pkg/api/application/execution.py b/src/langbot/pkg/api/application/execution.py index d2e77f2c1..f50c77bc0 100644 --- a/src/langbot/pkg/api/application/execution.py +++ b/src/langbot/pkg/api/application/execution.py @@ -2,7 +2,7 @@ Installation contexts are capabilities supplied by trusted Host code, never by a request body. A run context cannot fall back to legacy plugin-wide privileges. -The existing runtime consumers are deliberately not routed through this facade. +The Runtime protocol adapts its trusted envelope to this boundary. """ from __future__ import annotations @@ -35,6 +35,8 @@ class Output(BaseModel): def _input(name: str, **fields: Any) -> type[Input]: + if 'query_id' in fields: + fields['query_uuid'] = (Text | None, None) return create_model(name, __base__=Input, **fields) @@ -228,6 +230,7 @@ EXECUTION_OPERATIONS = ( tool_name=(Text, ...), parameters=(Object, {}), tool_parameters=(Object | None, None), + query_id=(QueryID | None, None), ), ToolOutput, 'plugin_or_run', @@ -632,8 +635,9 @@ def _fail(code: str, message: str, status: int = 400) -> None: class ExecutionAPI: """Invoke Host APIs with trusted installation/run capabilities, without a transport.""" - def __init__(self, ap: Any): + def __init__(self, ap: Any, *, handler: Any = None): self.ap = ap + self._handler = handler def operations(self) -> tuple[ExecutionOperation, ...]: return EXECUTION_OPERATIONS @@ -671,8 +675,8 @@ class ExecutionAPI: if 'tool_parameters' in data: _fail('invalid_input', 'Supply either parameters or tool_parameters') data['tool_parameters'] = data.pop('parameters') - handler = getattr(getattr(self.ap, 'plugin_connector', None), 'handler', None) - if handler is None or operation.action not in handler.actions: + handler = self._handler or getattr(getattr(self.ap, 'plugin_connector', None), 'handler', None) + if handler is None or operation.action not in handler._execution_actions: _fail('application_error', 'The execution Host does not provide this operation', 503) try: handler.validate_inbound_action_context(operation.action, context.binding) @@ -687,7 +691,7 @@ class ExecutionAPI: except (TypeError, ValueError): _fail('permission_denied', 'Installation binding is invalid or stale', 403) if context.run_id is not None: - if 'query_id' in data: + if 'query_id' in data or 'query_uuid' in data: _fail('invalid_input', 'A run context supplies its own query identity') from ...agent.runner.session_registry import get_session_registry from ...plugin.agent_run_support import _load_persistent_agent_run_session @@ -702,7 +706,7 @@ class ExecutionAPI: ): _fail('permission_denied', 'Run does not belong to this installation Workspace', 403) data['run_id'] = context.run_id - yield operation, handler.actions[operation.action], data + yield operation, handler._execution_actions[operation.action], data finally: handler._current_action_context.reset(token) diff --git a/src/langbot/pkg/api/application/plugin_operations.py b/src/langbot/pkg/api/application/plugin_operations.py index a53708701..1d9c5dff7 100644 --- a/src/langbot/pkg/api/application/plugin_operations.py +++ b/src/langbot/pkg/api/application/plugin_operations.py @@ -21,6 +21,7 @@ from pydantic import Field from ...core import taskmgr from ...entity.persistence import plugin as persistence_plugin from ...i18n import t +from ...operation_trace.service import changed_fields from ...plugin.archive import inspect_plugin_archive_metadata from ...plugin.github import validate_github_plugin_install_info from ...plugin.management import ( @@ -205,10 +206,16 @@ async def get_config(ap, context, payload): async def update_config(ap, context, payload): plugin = await _get(ap, context, payload) - config = restore_plugin_secret_placeholders(payload.config, await _stored_config(ap, context, payload, plugin)) + previous = await _stored_config(ap, context, payload, plugin) + config = restore_plugin_secret_placeholders(payload.config, previous) await _fence(ap, context) await ap.plugin_connector.set_plugin_config(payload.author, payload.plugin_name, config) - return {} + # Redact nested values before the audit formatter can turn them into strings. + return { + 'changes': changed_fields( + redact_plugin_secrets(previous) if isinstance(previous, dict) else {}, redact_plugin_secrets(config) + ) + } async def readme(ap, context, payload): diff --git a/src/langbot/pkg/api/application/service_operations.py b/src/langbot/pkg/api/application/service_operations.py index d09147b9c..a77f3e3a4 100644 --- a/src/langbot/pkg/api/application/service_operations.py +++ b/src/langbot/pkg/api/application/service_operations.py @@ -329,7 +329,7 @@ _BINDINGS = ( 'bot', 'BotService', 'preview_group_filter_migration', - 'RESOURCE_MANAGE', + 'RESOURCE_VIEW', False, ), ( @@ -759,12 +759,18 @@ _OUTPUT_TYPES = { _INTERNAL_PARAMETERS = {'preserve_uuid', 'default', 'on_result', 'for_inflight'} +class MissingResourceResult(WorkspaceNotFoundError): + """A service returned None; existing transports retain their missing-resource response.""" + + def _contract(operation_id, method): signature = inspect.signature(method) hints = typing.get_type_hints(method) fields = {} for name, parameter in signature.parameters.items(): - if name in {'self', 'context', 'ctx'} | _INTERNAL_PARAMETERS: + if name in {'self', 'context', 'ctx'} | _INTERNAL_PARAMETERS and not ( + operation_id == 'pipeline.create' and name == 'default' + ): continue annotation = _FIELD_TYPES.get(name, hints.get(name)) default = parameter.default @@ -777,9 +783,14 @@ def _contract(operation_id, method): default = ... if default is inspect.Parameter.empty else default constraints = {} if name in {'limit', 'max_count', 'max_entries'}: - constraints = {'ge': 1, 'le': 100_000 if '.export.' in operation_id else 1000} + maximum = 100_000 if '.export.' in operation_id else 1000 + if operation_id.startswith('monitoring.') and '.export.' not in operation_id: + from ..http.service.monitoring import _HARD_MAX_MONITORING_PAGE_ROWS + + maximum = _HARD_MAX_MONITORING_PAGE_ROWS + constraints = {'ge': 1, 'le': maximum} elif name in {'offset', 'from_index', 'after_sequence', 'before_id'}: - constraints = {'ge': 0} + constraints = {'ge': -1 if operation_id == 'bot.logs' and name == 'from_index' else 0} elif annotation is str and (name.endswith(('_uuid', '_id')) or name in {'query', 'uri'}): constraints = {'min_length': 1} fields[name] = (annotation, Field(default=default, **constraints)) @@ -808,7 +819,7 @@ def _operation(operation_id, service_name, method, permission, side_effect): if result is None and operation_id.endswith('.get') and output_type != type(None): # Optional defaults (e.g. starred model) are intentionally nullable. if operation_id not in {'model.llm.starred.get', 'model.llm.default.get'}: - raise WorkspaceNotFoundError('Resource not found') + raise MissingResourceResult('Resource not found') return result return Operation(operation_id, Permission[permission], invoke, input_type, output_type, side_effect) @@ -884,7 +895,7 @@ async def _assistant(ap, context, payload, method): class RoutePreviewInput(Input): bot_uuid: str = Field(min_length=1) - event_type: str = Field(min_length=1) + event_type: str | None event_data: dict | None = None event_context: dict | None = None event_bindings: list[dict] | None = None @@ -934,7 +945,7 @@ def _special_operations(): account = frozenset({PrincipalType.ACCOUNT}) result = [ Operation('pipeline.run', Permission.RUNTIME_OPERATE, _pipeline_run, PipelineRunInput, dict, True), - Operation('bot.routes.preview', Permission.RESOURCE_MANAGE, _route_preview, RoutePreviewInput, dict), + Operation('bot.routes.preview', Permission.RESOURCE_VIEW, _route_preview, RoutePreviewInput, dict), Operation( 'pipeline.migration.preview', Permission.RESOURCE_VIEW, _migration_preview, Input, dict, principals=account ), diff --git a/src/langbot/pkg/api/application/transport.py b/src/langbot/pkg/api/application/transport.py new file mode 100644 index 000000000..86885a3c4 --- /dev/null +++ b/src/langbot/pkg/api/application/transport.py @@ -0,0 +1,45 @@ +"""Adapt application failures to existing in-process transport contracts.""" + +from contextvars import ContextVar + +from .contracts import ApplicationAPIException + + +# HTTP already records the request's route, resource and before/after changes. +transport_records_audit = ContextVar('transport_records_application_audit', default=False) + + +class ApplicationInputError(ValueError): + """An invalid operation payload using the existing transport error contract.""" + + status_code = 400 + + +def raise_transport_error(exc: ApplicationAPIException): + if exc.error.code == 'invalid_input': + raise ApplicationInputError(exc.error.message) from exc + if exc.error.code != 'invalid_output' and exc.__cause__ is not None: + raise exc.__cause__ from None + raise exc + + +async def invoke_application(ap, operation_id, context, payload): + try: + return await ap.application_api.execute(operation_id, context, payload) + except ApplicationAPIException as exc: + from .service_operations import MissingResourceResult + + if isinstance(exc.__cause__, MissingResourceResult): + return None + raise_transport_error(exc) + + +async def stream_application(ap, operation_id, context, payload): + from contextlib import aclosing + + try: + async with aclosing(ap.application_api.stream(operation_id, context, payload)) as frames: + async for frame in frames: + yield frame + except ApplicationAPIException as exc: + raise_transport_error(exc) diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 6cafbdc8c..523daacdb 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -87,6 +87,30 @@ class RouterGroup(abc.ABC): self.ap = ap self.quart_app = quart_app + async def call_application(self, operation_id, context, payload): + from ...application.transport import invoke_application, transport_records_audit + + token = transport_records_audit.set(True) + try: + return await invoke_application(self.ap, operation_id, context, payload) + finally: + transport_records_audit.reset(token) + + async def stream_application(self, operation_id, context, payload): + from contextlib import aclosing + from ...application.transport import stream_application, transport_records_audit + + async with aclosing(stream_application(self.ap, operation_id, context, payload)) as frames: + while True: + token = transport_records_audit.set(True) + try: + frame = await anext(frames) + except StopAsyncIteration: + return + finally: + transport_records_audit.reset(token) + yield frame + @abc.abstractmethod async def initialize(self) -> None: pass @@ -283,6 +307,9 @@ class RouterGroup(abc.ABC): return response except Exception as e: # 自动 500 + from ...application.contracts import ApplicationAPIException + from ...application.transport import ApplicationInputError + if request_context is not None: await self._record_operation( request_context, @@ -296,6 +323,10 @@ class RouterGroup(abc.ABC): ) if isinstance(e, CodexProviderError): return self.http_status(e.status_code, e.error_code, str(e)) + if isinstance(e, ApplicationInputError): + return self.http_status(400, -1, str(e)) + if isinstance(e, ApplicationAPIException) and e.error.status_code < 500: + return self.http_status(e.error.status_code, e.error.code, e.error.message) if isinstance(e, EntitlementUnavailableError): return self.http_status(403, 'entitlement_unavailable', str(e)) if isinstance(e, AuthorizationError): @@ -364,6 +395,9 @@ class RouterGroup(abc.ABC): database, so a disabled Workspace pays nothing at steady state. """ + if getattr(quart.g, 'operation_log_recorded', False): + return + # Cheap global gate: a single attribute read on the application object. # While no Workspace has enabled traceability this is ``False`` and the # isolated subsystem stays entirely off the hot request path, with no @@ -454,6 +488,10 @@ class RouterGroup(abc.ABC): """Resolve the HTTP status of a handler result without consuming it.""" if failure is not None: + from ...application.contracts import ApplicationAPIException + + if isinstance(failure, ApplicationAPIException): + return failure.error.status_code status = getattr(failure, 'status_code', None) if isinstance(status, int): return status diff --git a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py index 700eab509..6ca75cbd1 100644 --- a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py +++ b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py @@ -19,30 +19,20 @@ from .....agent.runner.errors import ( ) -def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> quart.Response: +def debug_stream_response(router, context, agent_uuid: str, payload: dict) -> quart.Response: async def stream(): queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=32) - async def on_result(result: dict) -> None: - await queue.put({'kind': 'result', 'data': result}) - async def execute() -> None: - application = getattr(service, 'ap', None) - persistence_mgr = getattr(application, 'persistence_mgr', None) - tenant_scope = getattr(persistence_mgr, 'tenant_scope', None) - scope = tenant_scope(context.workspace_uuid) if callable(tenant_scope) else contextlib.nullcontext() + application = router.ap try: - # The streamed body is emitted after the request handler returned, - # so the request's tenant scope has already closed: carry the - # trusted Workspace identity into the deferred execution. - async with scope: - result = await service.debug_agent( - context, - agent_uuid, - payload, - on_result=on_result, + async with contextlib.aclosing( + router.stream_application( + 'agent.debug.stream', context, {'agent_uuid': agent_uuid, 'payload': payload} ) - await queue.put({'kind': 'completed', 'data': result}) + ) as frames: + async for frame in frames: + await queue.put(frame) except Exception as exc: if isinstance(exc, RunnerExecutionError): code, message = exc.error_code or 'runner_execution_failed', exc.message diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py index ed2c988c9..f661cb2fc 100644 --- a/src/langbot/pkg/api/http/controller/groups/agents.py +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -27,10 +27,10 @@ class AgentsRouterGroup(group.RouterGroup): async def processor_runs(agent_uuid: str, request_context: RequestContext): try: cursor = quart.request.args.get('before_id') - result = await self.ap.agent_service.get_processor_runs( + result = await self.call_application( + 'agent.runs.list', request_context, - agent_uuid, - before_id=int(cursor) if cursor else None, + {'processor_id': agent_uuid, 'before_id': int(cursor) if cursor else None}, ) return self.success(data=result) except ValueError as exc: @@ -45,11 +45,10 @@ class AgentsRouterGroup(group.RouterGroup): async def processor_run_events(agent_uuid: str, run_id: str, request_context: RequestContext): try: cursor = quart.request.args.get('after_sequence') - result = await self.ap.agent_service.get_processor_run_events( + result = await self.call_application( + 'agent.runs.events', request_context, - agent_uuid, - run_id, - after_sequence=int(cursor) if cursor else None, + {'processor_id': agent_uuid, 'run_id': run_id, 'after_sequence': int(cursor) if cursor else None}, ) return self.success(data=result) except ValueError as exc: @@ -65,7 +64,7 @@ class AgentsRouterGroup(group.RouterGroup): payload = await quart.request.get_json() if not isinstance(payload, dict): return self.http_status(400, -1, 'Debug payload must be an object') - return debug_stream_response(self.ap.agent_service, request_context, agent_uuid, payload) + return debug_stream_response(self, request_context, agent_uuid, payload) @self.route( '', @@ -79,10 +78,8 @@ class AgentsRouterGroup(group.RouterGroup): sort_order = quart.request.args.get('sort_order', 'DESC') return self.success( data={ - 'agents': await self.ap.agent_service.get_agents( - request_context, - sort_by, - sort_order, + 'agents': await self.call_application( + 'agent.list', request_context, {'sort_by': sort_by, 'sort_order': sort_order} ) } ) @@ -90,7 +87,7 @@ class AgentsRouterGroup(group.RouterGroup): json_data = await quart.request.json require_permission(request_context, Permission.RESOURCE_MANAGE) try: - created = await self.ap.agent_service.create_agent(request_context, json_data) + created = await self.call_application('agent.create', request_context, {'agent_data': json_data}) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data=created) @@ -102,7 +99,7 @@ class AgentsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - return self.success(data=await self.ap.agent_service.get_agent_metadata(request_context)) + return self.success(data=await self.call_application('agent.metadata', request_context, {})) @self.route( '//debug', @@ -113,10 +110,8 @@ class AgentsRouterGroup(group.RouterGroup): async def _(agent_uuid: str, request_context: RequestContext) -> str: json_data = await quart.request.json try: - result = await self.ap.agent_service.debug_agent( - request_context, - agent_uuid, - json_data or {}, + result = await self.call_application( + 'agent.debug', request_context, {'agent_uuid': agent_uuid, 'payload': json_data or {}} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -160,7 +155,7 @@ class AgentsRouterGroup(group.RouterGroup): ) async def _(agent_uuid: str, request_context: RequestContext) -> str: if quart.request.method == 'GET': - agent = await self.ap.agent_service.get_agent(request_context, agent_uuid) + agent = await self.call_application('agent.get', request_context, {'agent_uuid': agent_uuid}) if agent is None: return self.http_status(404, -1, 'agent not found') return self.success(data={'agent': agent}) @@ -169,11 +164,13 @@ class AgentsRouterGroup(group.RouterGroup): require_permission(request_context, Permission.RESOURCE_MANAGE) json_data = await quart.request.json try: - await self.ap.agent_service.update_agent(request_context, agent_uuid, json_data) + await self.call_application( + 'agent.update', request_context, {'agent_uuid': agent_uuid, 'agent_data': json_data} + ) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success() require_permission(request_context, Permission.RESOURCE_MANAGE) - await self.ap.agent_service.delete_agent(request_context, agent_uuid) + await self.call_application('agent.delete', request_context, {'agent_uuid': agent_uuid}) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/apikeys.py b/src/langbot/pkg/api/http/controller/groups/apikeys.py index 6c8b4a656..45efaad32 100644 --- a/src/langbot/pkg/api/http/controller/groups/apikeys.py +++ b/src/langbot/pkg/api/http/controller/groups/apikeys.py @@ -14,7 +14,7 @@ class ApiKeysRouterGroup(group.RouterGroup): async def initialize(self) -> None: @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) + keys = await self.call_application('api_key.list', request_context, {}) return self.success(data={'keys': keys}) @self.route('', methods=['POST'], permission=Permission.API_KEY_MANAGE) @@ -28,12 +28,15 @@ class ApiKeysRouterGroup(group.RouterGroup): except ValueError: return self.http_status(400, 'invalid_expiry', 'Invalid API key expiry') try: - key = await self.ap.apikey_service.create_api_key( + key = await self.call_application( + 'api_key.create', request_context, - json_data.get('name', ''), - json_data.get('description', ''), - scopes=json_data.get('scopes'), - expires_at=parsed_expiry, + { + 'name': json_data.get('name', ''), + 'description': 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)) @@ -41,7 +44,7 @@ class ApiKeysRouterGroup(group.RouterGroup): @self.route('/', 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) + key = await self.call_application('api_key.get', request_context, {'key_id': key_id}) if key is None: return self.http_status(404, 'resource_not_found', 'API key not found') return self.success(data={'key': key}) @@ -50,11 +53,10 @@ class ApiKeysRouterGroup(group.RouterGroup): async def _(key_id: int, request_context: RequestContext) -> str: json_data = await quart.request.json try: - await self.ap.apikey_service.update_api_key( + await self.call_application( + 'api_key.update', request_context, - key_id, - json_data.get('name'), - json_data.get('description'), + {'key_id': key_id, 'name': json_data.get('name'), 'description': json_data.get('description')}, ) except ValueError as error: return self.http_status(400, 'invalid_api_key', str(error)) @@ -62,5 +64,5 @@ class ApiKeysRouterGroup(group.RouterGroup): @self.route('/', 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) + await self.call_application('api_key.delete', request_context, {'key_id': key_id}) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/assistant.py b/src/langbot/pkg/api/http/controller/groups/assistant.py index 7684db2e1..97abf3485 100644 --- a/src/langbot/pkg/api/http/controller/groups/assistant.py +++ b/src/langbot/pkg/api/http/controller/groups/assistant.py @@ -1,7 +1,8 @@ -"""Web-session-only assistant endpoints; resource tools use the existing service layer.""" +"""Web-session-only assistant endpoints backed by the Application API.""" import asyncio import json +from contextlib import aclosing import quart from uuid import UUID @@ -10,7 +11,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError from .. import group from ...authz import Permission from ...context import RequestContext -from ...service.assistant import AssistantError, AssistantService +from ...service.assistant import AssistantError class StopInput(BaseModel): @@ -29,17 +30,23 @@ class TurnInput(BaseModel): @group.group_class('assistant', '/api/v1/assistant') class AssistantRouterGroup(group.RouterGroup): async def initialize(self): - service = AssistantService(self.ap) - @self.route('/conversations', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def list_conversations(request_context: RequestContext): - return self.success(data={'conversations': await service.list_conversations(request_context)}) + return self.success( + data={'conversations': await self.call_application('assistant.list', request_context, {})} + ) @self.route('/conversations//stop', methods=['POST'], permission=Permission.RUNTIME_OPERATE) async def stop(conversation_id: str, request_context: RequestContext): try: body = StopInput.model_validate(await quart.request.get_json()) - return self.success(data=await service.stop(request_context, conversation_id, body.revision)) + return self.success( + data=await self.call_application( + 'assistant.stop', + request_context, + {'conversation_id': conversation_id, 'revision': body.revision}, + ) + ) except ValidationError: return self.http_status(400, 'invalid_input', 'Invalid stop request') except AssistantError as exc: @@ -48,21 +55,27 @@ class AssistantRouterGroup(group.RouterGroup): @self.route('/recommended-model', methods=['GET'], permission=Permission.RUNTIME_OPERATE) async def recommended_model(request_context: RequestContext): try: - return self.success(data=await service.recommended_model(request_context)) + return self.success( + data=await self.call_application('assistant.recommended_model', request_context, {}) + ) except AssistantError as exc: return self.http_status(exc.status, exc.code, exc.code) @self.route('/conversations', methods=['POST'], permission=Permission.RUNTIME_OPERATE) async def create(request_context: RequestContext): try: - return self.success(data=service.public_view(await service.create(request_context))) + return self.success(data=await self.call_application('assistant.create', request_context, {})) except AssistantError as exc: return self.http_status(exc.status, exc.code, exc.code) @self.route('/conversations/', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get(conversation_id: str, request_context: RequestContext): try: - return self.success(data=await service.progress(request_context, conversation_id)) + return self.success( + data=await self.call_application( + 'assistant.get', request_context, {'conversation_id': conversation_id} + ) + ) except AssistantError as exc: return self.http_status(exc.status, exc.code, exc.code) @@ -72,15 +85,12 @@ class AssistantRouterGroup(group.RouterGroup): body = TurnInput.model_validate(await quart.request.get_json()) if (body.text is None) == (body.approved is None) or (body.text is not None and not body.text.strip()): return self.http_status(400, 'invalid_input', 'Provide text or an approval decision') - conversation = await service.turn( + conversation = await self.call_application( + 'assistant.turn', request_context, - conversation_id, - body.revision, - body.text, - body.approved, - str(body.model_uuid) if body.model_uuid else None, + {'conversation_id': conversation_id, **body.model_dump(mode='json')}, ) - return self.success(data=service.public_view(conversation)) + return self.success(data=conversation) except ValidationError: return self.http_status(400, 'invalid_input', 'Invalid assistant request') except AssistantError as exc: @@ -94,31 +104,43 @@ class AssistantRouterGroup(group.RouterGroup): body = TurnInput.model_validate(await quart.request.get_json()) if (body.text is None) == (body.approved is None) or (body.text is not None and not body.text.strip()): return self.http_status(400, 'invalid_input', 'Provide text or an approval decision') - await service.get(request_context, conversation_id) except ValidationError: return self.http_status(400, 'invalid_input', 'Invalid assistant request') - except AssistantError as exc: - return self.http_status(exc.status, exc.code, exc.code) try: - return assistant_stream_response(service, request_context, conversation_id, body) + return await assistant_stream_response( + self.stream_application( + 'assistant.stream', + request_context, + {'conversation_id': conversation_id, **body.model_dump(mode='json')}, + ) + ) except AssistantError as exc: return self.http_status(exc.status, exc.code, exc.code) -def assistant_stream_response(service, context, conversation_id, body): - """Detach execution from the subscriber; only an explicit stop cancels a run.""" - queue = service.start_run(context, conversation_id, body) +async def assistant_stream_response(source): + """Keep the run alive after disconnect; only an explicit stop cancels it.""" + # Resolve startup failures before sending HTTP headers. + first = await anext(source) async def stream(): - while True: - try: - frame = await asyncio.wait_for(queue.get(), timeout=15) - except TimeoutError: - yield '\n' - continue - yield json.dumps(frame, ensure_ascii=False) + '\n' - if frame['kind'] in {'completed', 'error'}: - break + async with aclosing(source): + frame = first + while True: + yield json.dumps(frame, ensure_ascii=False) + '\n' + if frame['kind'] in {'completed', 'error'}: + return + pending = asyncio.create_task(anext(source)) + try: + while not (await asyncio.wait({pending}, timeout=15))[0]: + yield '\n' + try: + frame = pending.result() + except StopAsyncIteration: + return + finally: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) response = quart.Response(stream(), content_type='application/x-ndjson; charset=utf-8') response.timeout = None diff --git a/src/langbot/pkg/api/http/controller/groups/box.py b/src/langbot/pkg/api/http/controller/groups/box.py index 99ba7241c..95993f18f 100644 --- a/src/langbot/pkg/api/http/controller/groups/box.py +++ b/src/langbot/pkg/api/http/controller/groups/box.py @@ -21,7 +21,7 @@ class BoxRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: try: - status = await self.ap.box_service.get_status(request_context) + status = await self.call_application('box.status', request_context, {}) except (BoxAdmissionError, EntitlementUnavailableError) as exc: return self.http_status(403, 'managed_sandbox_unavailable', str(exc)) status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled')) @@ -47,7 +47,7 @@ class BoxRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: try: - sessions = await self.ap.box_service.get_sessions(request_context) + sessions = await self.call_application('box.sessions.list', request_context, {}) except (BoxAdmissionError, EntitlementUnavailableError) as exc: return self.http_status(403, 'managed_sandbox_unavailable', str(exc)) return self.success(data=sessions) @@ -60,9 +60,7 @@ class BoxRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: try: - if getattr(self.ap.box_service, 'managed_admission_required', False): - await self.ap.box_service.require_workspace_sandbox(request_context) + errors = await self.call_application('box.errors.list', request_context, {}) except (BoxAdmissionError, EntitlementUnavailableError) as exc: return self.http_status(403, 'managed_sandbox_unavailable', str(exc)) - errors = self.ap.box_service.get_recent_errors(request_context) return self.success(data=errors) diff --git a/src/langbot/pkg/api/http/controller/groups/extensions.py b/src/langbot/pkg/api/http/controller/groups/extensions.py index 39fd8f202..8adc560a5 100644 --- a/src/langbot/pkg/api/http/controller/groups/extensions.py +++ b/src/langbot/pkg/api/http/controller/groups/extensions.py @@ -23,22 +23,14 @@ class ExtensionsRouterGroup(group.RouterGroup): 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) - - async def read_in_task_scope(operation): - tenant_scope = getattr(getattr(self.ap, 'persistence_mgr', None), 'tenant_scope', None) - if callable(tenant_scope): - async with tenant_scope(request_context.workspace_uuid): - return await operation() - return await operation() - plugins, mcp_servers, skills = await asyncio.gather( - read_in_task_scope(self.ap.plugin_connector.list_plugins), - read_in_task_scope( - lambda: self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True) - ), - read_in_task_scope(lambda: self.ap.skill_service.list_skills(request_context)), + self.call_application('plugin.list', request_context, {}), + self.call_application('mcp.server.list', request_context, {'contain_runtime_info': True}), + self.call_application('skill.list', request_context, {}), return_exceptions=True, ) + if isinstance(plugins, dict): + plugins = plugins['plugins'] def _sort_key(item: dict) -> tuple[int, str]: is_debug = 0 if (item.get('type') == 'plugin' and item.get('plugin', {}).get('debug')) else 1 diff --git a/src/langbot/pkg/api/http/controller/groups/files.py b/src/langbot/pkg/api/http/controller/groups/files.py index e38c181ae..80ffbb4fe 100644 --- a/src/langbot/pkg/api/http/controller/groups/files.py +++ b/src/langbot/pkg/api/http/controller/groups/files.py @@ -2,7 +2,6 @@ from __future__ import annotations import quart import mimetypes -import uuid import asyncio import quart.datastructures @@ -12,14 +11,6 @@ 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: @@ -105,21 +96,10 @@ class FilesRouterGroup(group.RouterGroup): if '/' in file_name or '\\' in file_name: return self.fail(400, 'File name contains invalid characters') - logical_key = f'{uuid.uuid4()}.{extension}' - - # save file to storage - 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, - } + result = await self.call_application( + 'file.image.upload', request_context, {'filename': file.filename, 'content': file_bytes} ) + return self.success(data=result) @self.route( '/documents', @@ -151,29 +131,15 @@ class FilesRouterGroup(group.RouterGroup): # Split filename and extension properly if '.' in file.filename: - file_name, extension = file.filename.rsplit('.', 1) + file_name = file.filename.rsplit('.', 1)[0] else: file_name = file.filename - extension = '' # check if file name contains '/' or '\' if '/' in file_name or '\\' in file_name: return self.fail(400, 'File name contains invalid characters') - logical_key = str(uuid.uuid4()) - if extension: - logical_key += '.' + extension - - # save file to storage - 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, - } + result = await self.call_application( + 'file.document.upload', request_context, {'filename': file.filename, 'content': file_bytes} ) + return self.success(data=result) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py index d8970d5ad..0a3a33f93 100644 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py @@ -16,9 +16,10 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def handle_knowledge_bases(request_context: RequestContext) -> quart.Response: - knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases( + knowledge_bases = await self.call_application( + 'knowledge.base.list', request_context, - include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE), + {'include_secret': has_permission(request_context, Permission.RESOURCE_MANAGE)}, ) return self.success(data={'bases': knowledge_bases}) @@ -31,9 +32,8 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): 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, + knowledge_base_uuid = await self.call_application( + 'knowledge.base.create', request_context, {'kb_data': json_data} ) except ValueError as e: return self.http_status(400, -1, str(e)) @@ -49,10 +49,13 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): knowledge_base_uuid: str, request_context: RequestContext, ) -> quart.Response: - knowledge_base = await self.ap.knowledge_service.get_knowledge_base( + knowledge_base = await self.call_application( + 'knowledge.base.get', request_context, - knowledge_base_uuid, - include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE), + { + 'kb_uuid': 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') @@ -74,17 +77,13 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): if quart.request.method == 'PUT': json_data = await quart.request.json try: - previous = await self.ap.knowledge_service.get_knowledge_base( - request_context, - knowledge_base_uuid, - include_secret=True, + previous = await self.call_application( + 'knowledge.base.get', request_context, {'kb_uuid': knowledge_base_uuid, 'include_secret': True} ) except Exception: previous = None - await self.ap.knowledge_service.update_knowledge_base( - request_context, - knowledge_base_uuid, - json_data, + await self.call_application( + 'knowledge.base.update', request_context, {'kb_uuid': knowledge_base_uuid, 'kb_data': json_data} ) changes = settings_service.changed_fields( previous if isinstance(previous, dict) else {}, @@ -96,7 +95,7 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): if rule is not None and changes: quart.g.operation_log_summary = settings_service.build_summary(rule, changes) return self.success(data={'uuid': knowledge_base_uuid}) - await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid) + await self.call_application('knowledge.base.delete', request_context, {'kb_uuid': knowledge_base_uuid}) return self.success({}) @self.route( @@ -109,9 +108,8 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): knowledge_base_uuid: str, request_context: RequestContext, ) -> str: - files = await self.ap.knowledge_service.get_files_by_knowledge_base( - request_context, - knowledge_base_uuid, + files = await self.call_application( + 'knowledge.file.list', request_context, {'kb_uuid': knowledge_base_uuid} ) return self.success(data={'files': files}) @@ -130,11 +128,10 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): 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( + task_id = await self.call_application( + 'knowledge.file.store', request_context, - knowledge_base_uuid, - file_id, - parser_plugin_id=parser_plugin_id, + {'kb_uuid': knowledge_base_uuid, 'file_id': file_id, 'parser_plugin_id': parser_plugin_id}, ) return self.success({'task_id': task_id}) @@ -149,7 +146,9 @@ class KnowledgeBaseRouterGroup(group.RouterGroup): knowledge_base_uuid: str, request_context: RequestContext, ) -> str: - await self.ap.knowledge_service.delete_file(request_context, knowledge_base_uuid, file_id) + await self.call_application( + 'knowledge.file.delete', request_context, {'kb_uuid': knowledge_base_uuid, 'file_id': file_id} + ) return self.success({}) @self.route( @@ -170,10 +169,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( + results = await self.call_application( + 'knowledge.base.retrieve', request_context, - knowledge_base_uuid, - query, - retrieval_settings, + {'kb_uuid': knowledge_base_uuid, 'query': query, 'retrieval_settings': retrieval_settings}, ) return self.success(data={'results': results}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py index 02d047f15..0df4841d9 100644 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py @@ -21,7 +21,7 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup): 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(request_context) + engines = await self.call_application('knowledge.engine.list', request_context, {}) return self.success(data={'engines': engines}) @self.route( @@ -41,7 +41,9 @@ 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(request_context, plugin_id) + schema = await self.call_application( + 'knowledge.engine.creation_schema', request_context, {'plugin_id': plugin_id} + ) return self.success(data={'schema': schema}) @self.route( @@ -61,5 +63,7 @@ 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(request_context, plugin_id) + schema = await self.call_application( + 'knowledge.engine.retrieval_schema', request_context, {'plugin_id': plugin_id} + ) return self.success(data={'schema': schema}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py index 495539307..319cb15b2 100644 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py @@ -20,5 +20,5 @@ class ParsersRouterGroup(group.RouterGroup): 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(request_context, mime_type) + parsers = await self.call_application('knowledge.parser.list', request_context, {'mime_type': mime_type}) return self.success(data={'parsers': parsers}) diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index b92ea5d72..75eb81bf4 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -59,12 +59,15 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - metrics = await self.ap.monitoring_service.get_overview_metrics( + metrics = await self.call_application( + 'monitoring.overview', request_context, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + }, ) return self.success(data=metrics) @@ -80,7 +83,7 @@ class MonitoringRouterGroup(group.RouterGroup): async def snapshot(): nonlocal previous - result = await self.ap.monitoring_service.get_inflight_snapshot(request_context, previous) + result = await self.call_application('monitoring.inflight', request_context, {'previous': previous}) previous = [row for row in result['items'] if row['status_group'] in {'running', 'queued'}] return result @@ -97,6 +100,9 @@ class MonitoringRouterGroup(group.RouterGroup): @self.route('/executions', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_executions(request_context: RequestContext) -> str: """Unified execution list across agent runs and pipeline queries.""" + limit, offset = self.ap.monitoring_service.normalize_page_window( + quart.request.args.get('limit', 50), quart.request.args.get('offset', 0) + ) bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') agent_ids = quart.request.args.getlist('agentId') @@ -107,18 +113,21 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(quart.request.args.get('endTime')) validate_monitoring_window(start_time, end_time) - result = await self.ap.monitoring_service.get_executions( + result = await self.call_application( + 'monitoring.executions', request_context, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - agent_ids=agent_ids if agent_ids else None, - statuses=statuses if statuses else None, - source=source, - mode=mode, - start_time=start_time, - end_time=end_time, - limit=quart.request.args.get('limit', 50), - offset=quart.request.args.get('offset', 0), + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'agent_ids': agent_ids if agent_ids else None, + 'statuses': statuses if statuses else None, + 'source': source, + 'mode': mode, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': offset, + }, ) return self.success(data=result) @@ -130,14 +139,20 @@ class MonitoringRouterGroup(group.RouterGroup): ) async def get_execution_detail(source: str, execution_id: str, request_context: RequestContext) -> str: """Read a full trace, its initial overview, or one paginated section.""" + limit, offset = self.ap.monitoring_service.normalize_page_window( + quart.request.args.get('limit', 100), quart.request.args.get('offset', 0) + ) try: - detail = await self.ap.monitoring_service.get_execution_detail( + detail = await self.call_application( + 'monitoring.execution.detail', request_context, - source, - execution_id, - section=quart.request.args.get('section'), - offset=quart.request.args.get('offset', 0), - limit=quart.request.args.get('limit', 100), + { + 'source': source, + 'execution_id': execution_id, + 'section': quart.request.args.get('section'), + 'offset': offset, + 'limit': limit, + }, ) except ValueError as exc: return self.fail(404, str(exc)) @@ -158,15 +173,18 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - stats = await self.ap.monitoring_service.get_token_statistics( + stats = await self.call_application( + 'monitoring.tokens', request_context, - execution_statuses=quart.request.args.getlist('status') or None, - mode=quart.request.args.get('mode', 'all'), - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, - bucket=bucket, + { + 'execution_statuses': quart.request.args.getlist('status') or None, + 'mode': quart.request.args.get('mode', 'all'), + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'bucket': bucket, + }, ) return self.success(data=stats) @@ -194,15 +212,18 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - messages, total = await self.ap.monitoring_service.get_messages( + messages, total = await self.call_application( + 'monitoring.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, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=offset, + { + '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, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': offset, + }, ) return self.success( @@ -236,14 +257,17 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - llm_calls, total = await self.ap.monitoring_service.get_llm_calls( + llm_calls, total = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, - offset=offset, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': offset, + }, ) return self.success( @@ -276,15 +300,18 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - tool_calls, total = await self.ap.monitoring_service.get_tool_calls( + tool_calls, total = await self.call_application( + 'monitoring.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, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=offset, + { + '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, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': offset, + }, ) return self.success( @@ -317,13 +344,16 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls( + embedding_calls, total = await self.call_application( + 'monitoring.embedding_calls', request_context, - start_time=start_time, - end_time=end_time, - knowledge_base_id=knowledge_base_id if knowledge_base_id else None, - limit=limit, - offset=offset, + { + 'start_time': start_time, + 'end_time': end_time, + 'knowledge_base_id': knowledge_base_id if knowledge_base_id else None, + 'limit': limit, + 'offset': offset, + }, ) return self.success( @@ -368,16 +398,19 @@ class MonitoringRouterGroup(group.RouterGroup): if is_active_str: is_active = is_active_str.lower() == 'true' - sessions, total = await self.ap.monitoring_service.get_sessions( + sessions, total = await self.call_application( + 'monitoring.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, - end_time=end_time, - user_query=user_query, - is_active=is_active, - limit=limit, - offset=offset, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'user_query': user_query, + 'is_active': is_active, + 'limit': limit, + 'offset': offset, + }, ) return self.success( @@ -411,14 +444,17 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - errors, total = await self.ap.monitoring_service.get_errors( + errors, total = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, - offset=offset, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': offset, + }, ) return self.success( @@ -450,83 +486,98 @@ class MonitoringRouterGroup(group.RouterGroup): validate_monitoring_window(start_time, end_time) # Get overview metrics - overview = await self.ap.monitoring_service.get_overview_metrics( + overview = await self.call_application( + 'monitoring.overview', request_context, - **execution_filters, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, + { + **execution_filters, + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + }, ) # Get messages - messages, messages_total = await self.ap.monitoring_service.get_messages( + messages, messages_total = await self.call_application( + 'monitoring.messages', request_context, - **execution_filters, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=0, + { + **execution_filters, + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': 0, + }, ) # Get LLM calls - llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls( + llm_calls, llm_calls_total = await self.call_application( + 'monitoring.llm_calls', request_context, - **execution_filters, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=0, + { + **execution_filters, + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': 0, + }, ) # Get tool calls - tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls( + tool_calls, tool_calls_total = await self.call_application( + 'monitoring.tool_calls', request_context, - **execution_filters, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=0, + { + **execution_filters, + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': 0, + }, ) # Sessions have conversation scope, independent of execution status/mode. - sessions, sessions_total = await self.ap.monitoring_service.get_sessions( + sessions, sessions_total = await self.call_application( + 'monitoring.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, - end_time=end_time, - is_active=None, - limit=limit, - offset=0, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'is_active': None, + 'limit': limit, + 'offset': 0, + }, ) # Get errors - errors, errors_total = await self.ap.monitoring_service.get_errors( + errors, errors_total = await self.call_application( + 'monitoring.errors', request_context, - **execution_filters, - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=0, + { + **execution_filters, + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': 0, + }, ) # Get embedding calls - embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls( + embedding_calls, embedding_calls_total = await self.call_application( + 'monitoring.embedding_calls', request_context, - **execution_filters, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=0, + {**execution_filters, 'start_time': start_time, 'end_time': end_time, 'limit': limit, 'offset': 0}, ) return self.success( @@ -569,10 +620,10 @@ class MonitoringRouterGroup(group.RouterGroup): if not isinstance(body, dict): return self.http_status(400, 'invalid_request', 'Expected a JSON object') try: - result = await self.ap.monitoring_service.reset_session_context( + result = await self.call_application( + 'monitoring.session.reset', request_context, - body.get('bot_id'), - body.get('session_id'), + {'bot_id': body.get('bot_id'), 'session_id': body.get('session_id')}, ) except ValueError as exc: return self.http_status(400, 'invalid_request', str(exc)) @@ -594,12 +645,15 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(quart.request.args.get('endTime')) # Message pages can contain one timestamp; tool-call bounds are inclusive. validate_monitoring_window(start_time, end_time, allow_point_window=True) - analysis = await self.ap.monitoring_service.get_session_analysis( + analysis = await self.call_application( + 'monitoring.session.analysis', request_context, - session_id, - start_time=start_time, - end_time=end_time, - bot_id=quart.request.args.get('botId'), + { + 'session_id': session_id, + 'start_time': start_time, + 'end_time': end_time, + 'bot_id': quart.request.args.get('botId'), + }, ) # Always return success with the analysis data @@ -614,7 +668,9 @@ class MonitoringRouterGroup(group.RouterGroup): ) 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(request_context, message_id) + details = await self.call_application( + 'monitoring.message.detail', request_context, {'message_id': message_id} + ) if not details.get('found'): return self.http_status(404, 'resource_not_found', 'Message not found') @@ -639,13 +695,16 @@ class MonitoringRouterGroup(group.RouterGroup): # Get data based on export type if export_type == 'messages': - data = await self.ap.monitoring_service.export_messages( + data = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + }, ) headers = [ 'id', @@ -664,13 +723,16 @@ class MonitoringRouterGroup(group.RouterGroup): 'user_id', ] elif export_type == 'llm-calls': - data = await self.ap.monitoring_service.export_llm_calls( + data = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + }, ) headers = [ 'id', @@ -691,11 +753,10 @@ class MonitoringRouterGroup(group.RouterGroup): 'error_message', ] elif export_type == 'embedding-calls': - data = await self.ap.monitoring_service.export_embedding_calls( + data = await self.call_application( + 'monitoring.export.embedding_calls', request_context, - start_time=start_time, - end_time=end_time, - limit=limit, + {'start_time': start_time, 'end_time': end_time, 'limit': limit}, ) headers = [ 'id', @@ -714,13 +775,16 @@ class MonitoringRouterGroup(group.RouterGroup): 'call_type', ] elif export_type == 'errors': - data = await self.ap.monitoring_service.export_errors( + data = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + }, ) headers = [ 'id', @@ -736,13 +800,16 @@ class MonitoringRouterGroup(group.RouterGroup): 'stack_trace', ] elif export_type == 'sessions': - data = await self.ap.monitoring_service.export_sessions( + data = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + }, ) headers = [ 'session_id', @@ -758,13 +825,16 @@ class MonitoringRouterGroup(group.RouterGroup): 'user_id', ] elif export_type == 'feedback': - data = await self.ap.monitoring_service.export_feedback( + data = await self.call_application( + 'monitoring.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, - end_time=end_time, - limit=limit, + { + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + }, ) headers = [ 'id', @@ -828,14 +898,17 @@ class MonitoringRouterGroup(group.RouterGroup): end_time = parse_iso_datetime(end_time_str) validate_monitoring_window(start_time, end_time) - stats = await self.ap.monitoring_service.get_feedback_stats( + stats = await self.call_application( + 'monitoring.feedback.stats', request_context, - execution_statuses=quart.request.args.getlist('status') or None, - mode=quart.request.args.get('mode', 'all'), - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - start_time=start_time, - end_time=end_time, + { + 'execution_statuses': quart.request.args.getlist('status') or None, + 'mode': quart.request.args.get('mode', 'all'), + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'start_time': start_time, + 'end_time': end_time, + }, ) return self.success(data=stats) @@ -860,17 +933,20 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse feedback type feedback_type = int(feedback_type_str) if feedback_type_str else None - feedback_list, total = await self.ap.monitoring_service.get_feedback_list( + feedback_list, total = await self.call_application( + 'monitoring.feedback.list', request_context, - execution_statuses=quart.request.args.getlist('status') or None, - mode=quart.request.args.get('mode', 'all'), - bot_ids=bot_ids if bot_ids else None, - pipeline_ids=pipeline_ids if pipeline_ids else None, - feedback_type=feedback_type, - start_time=start_time, - end_time=end_time, - limit=limit, - offset=offset, + { + 'execution_statuses': quart.request.args.getlist('status') or None, + 'mode': quart.request.args.get('mode', 'all'), + 'bot_ids': bot_ids if bot_ids else None, + 'pipeline_ids': pipeline_ids if pipeline_ids else None, + 'feedback_type': feedback_type, + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + 'offset': offset, + }, ) return self.success( diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py b/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py index 238a1fa89..70442495c 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py @@ -5,20 +5,18 @@ import quart from ... import group from ....authz import Permission from ....context import RequestContext -from ....service.pipeline_migration import PipelineMigrationService, MigrationError +from ....service.pipeline_migration import MigrationError @group.group_class('pipeline_migration', '/api/v1/pipelines/_/migration') class PipelineMigrationRouterGroup(group.RouterGroup): async def initialize(self): - self.service = PipelineMigrationService(self.ap) - @self.route( '/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_VIEW ) async def preview(request_context: RequestContext): try: - return self.success(data=await self.service.preview(request_context)) + return self.success(data=await self.call_application('pipeline.migration.preview', request_context, {})) except MigrationError as exc: return self.http_status(exc.status_code, -1, exc.code) @@ -28,6 +26,8 @@ class PipelineMigrationRouterGroup(group.RouterGroup): async def execute(request_context: RequestContext): try: body = await quart.request.get_json(silent=True) - return self.success(data=await self.service.execute(request_context, body)) + return self.success( + data=await self.call_application('pipeline.migration.execute', request_context, {'body': body}) + ) except MigrationError as exc: return self.http_status(exc.status_code, -1, exc.code) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 33bc79ac8..340938f65 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -5,8 +5,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext from ......operation_trace import service as settings_service -from ....service.secrets import redact_secrets -from ....service.pipeline_run import run_pipeline +from ......workspace.errors import WorkspaceNotFoundError from ... import group from ......pipeline.extension_preferences import ( normalize_extension_preferences, @@ -29,11 +28,10 @@ class PipelinesRouterGroup(group.RouterGroup): include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE) return self.success( data={ - 'pipelines': await self.ap.pipeline_service.get_pipelines( + 'pipelines': await self.call_application( + 'pipeline.list', request_context, - sort_by, - sort_order, - include_secret=include_secret, + {'sort_by': sort_by, 'sort_order': sort_order, 'include_secret': include_secret}, ) } ) @@ -48,10 +46,8 @@ class PipelinesRouterGroup(group.RouterGroup): pipeline_data = await quart.request.json create_as_default = pipeline_data.get('is_default') is True try: - pipeline_uuid = await self.ap.pipeline_service.create_pipeline( - request_context, - pipeline_data, - default=create_as_default, + pipeline_uuid = await self.call_application( + 'pipeline.create', request_context, {'pipeline_data': pipeline_data, 'default': create_as_default} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -64,7 +60,7 @@ class PipelinesRouterGroup(group.RouterGroup): 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={'configs': await self.call_application('pipeline.metadata', request_context, {})}) @self.route( '/', @@ -73,10 +69,13 @@ class PipelinesRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(pipeline_uuid: str, request_context: RequestContext) -> str: - pipeline = await self.ap.pipeline_service.get_pipeline( + pipeline = await self.call_application( + 'pipeline.get', request_context, - pipeline_uuid, - include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE), + { + 'pipeline_uuid': pipeline_uuid, + 'include_secret': has_permission(request_context, Permission.RESOURCE_MANAGE), + }, ) if pipeline is None: return self.http_status(404, -1, 'pipeline not found') @@ -93,14 +92,14 @@ class PipelinesRouterGroup(group.RouterGroup): if quart.request.method == 'PUT': json_data = await quart.request.json try: - previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) + previous = await self.call_application( + 'pipeline.get', request_context, {'pipeline_uuid': pipeline_uuid} + ) except Exception: previous = None try: - await self.ap.pipeline_service.update_pipeline( - request_context, - pipeline_uuid, - json_data, + await self.call_application( + 'pipeline.update', request_context, {'pipeline_uuid': pipeline_uuid, 'pipeline_data': json_data} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -114,7 +113,7 @@ class PipelinesRouterGroup(group.RouterGroup): if rule is not None and changes: quart.g.operation_log_summary = settings_service.build_summary(rule, changes) else: - await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid) + await self.call_application('pipeline.delete', request_context, {'pipeline_uuid': pipeline_uuid}) return self.success() @self.route( @@ -125,7 +124,9 @@ class PipelinesRouterGroup(group.RouterGroup): ) async def _(pipeline_uuid: str, request_context: RequestContext) -> str: try: - new_uuid = await self.ap.pipeline_service.copy_pipeline(request_context, pipeline_uuid) + new_uuid = await self.call_application( + 'pipeline.copy', request_context, {'pipeline_uuid': pipeline_uuid} + ) return self.success(data={'uuid': new_uuid}) except ValueError as e: return self.http_status(400, -1, str(e)) @@ -137,36 +138,13 @@ class PipelinesRouterGroup(group.RouterGroup): 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) - if pipeline is None: - return self.http_status(404, -1, 'pipeline not found') - - 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) try: - available_skills = await self.ap.skill_service.list_skills(request_context) - except Exception as exc: - self.ap.logger.warning('Unable to list skills for pipeline extensions: %s', exc) - available_skills = [] - extensions_prefs = normalize_extension_preferences(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, - } - ) + extensions = await self.call_application( + 'pipeline.extensions.get', request_context, {'pipeline_uuid': pipeline_uuid} + ) + except WorkspaceNotFoundError: + return self.http_status(404, -1, 'pipeline not found') + return self.success(data=extensions) @self.route( '//extensions', @@ -181,7 +159,9 @@ class PipelinesRouterGroup(group.RouterGroup): # into what": without this the log could only say an extension was # configured, never which binding was added or removed. try: - previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) + previous = await self.call_application( + 'pipeline.get', request_context, {'pipeline_uuid': pipeline_uuid} + ) except Exception: previous = None # The request uses ``bound_*`` keys while the stored preferences use @@ -234,17 +214,20 @@ class PipelinesRouterGroup(group.RouterGroup): 'mcp_resources': 'bound_mcp_resources', }, ) - await self.ap.pipeline_service.update_pipeline_extensions( + await self.call_application( + 'pipeline.extensions.update', 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'), + { + 'pipeline_uuid': pipeline_uuid, + 'bound_plugins': json_data.get('bound_plugins', []), + 'bound_mcp_servers': json_data.get('bound_mcp_servers', []), + 'enable_all_plugins': json_data.get('enable_all_plugins', True), + 'enable_all_mcp_servers': 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'), + }, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -263,4 +246,8 @@ class PipelinesRouterGroup(group.RouterGroup): text = body['message'] if not isinstance(text, str) or not text.strip() or len(text) > 100_000: return self.http_status(400, -1, 'message must contain 1..100000 characters') - return self.success(data=await run_pipeline(self.ap, request_context, pipeline_uuid, text)) + return self.success( + data=await self.call_application( + 'pipeline.run', request_context, {'pipeline_uuid': pipeline_uuid, 'text': text} + ) + ) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index 649293e10..606e9e1fd 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -316,10 +316,17 @@ class WebSocketChatRouterGroup(group.RouterGroup): if session_type not in ['person', 'group']: return self.http_status(400, -1, 'session_type must be person or group') - websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid) - if websocket_adapter is None: + try: + messages = await self.call_application( + 'pipeline.chat.history', + request_context, + { + 'pipeline_uuid': pipeline_uuid, + 'session_type': session_type, + }, + ) + except FileNotFoundError: 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}) @self.route( @@ -336,10 +343,17 @@ class WebSocketChatRouterGroup(group.RouterGroup): if session_type not in ['person', 'group']: return self.http_status(400, -1, 'session_type must be person or group') - websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid) - if websocket_adapter is None: + try: + await self.call_application( + 'pipeline.chat.reset', + request_context, + { + 'pipeline_uuid': pipeline_uuid, + 'session_type': session_type, + }, + ) + except FileNotFoundError: 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'}) @self.route( @@ -349,30 +363,13 @@ class WebSocketChatRouterGroup(group.RouterGroup): 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: + try: + result = await self.call_application( + 'pipeline.chat.connections', request_context, {'pipeline_uuid': pipeline_uuid} + ) + except FileNotFoundError: return self.http_status(404, -1, 'Pipeline not found') - - 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 - ], - } - ) + return self.success(data=result) @self.route( '/broadcast', @@ -381,24 +378,21 @@ class WebSocketChatRouterGroup(group.RouterGroup): 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') - 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(), - } - await ws_connection_manager.broadcast_to_pipeline( - pipeline_uuid, - broadcast_data, - scope=WebSocketScope.from_context(request_context), - ) + try: + await self.call_application( + 'pipeline.chat.broadcast', + request_context, + { + 'pipeline_uuid': pipeline_uuid, + 'message': message, + }, + ) + except FileNotFoundError: + return self.http_status(404, -1, 'Pipeline not found') return self.success(data={'message': 'Broadcast sent successfully'}) async def _handle_receive( diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 590d82dbb..8895f351a 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -6,8 +6,8 @@ import quart from langbot.pkg.api.http.authz import Permission from langbot.pkg.api.http.context import RequestContext -from langbot.pkg.cloud.plan_policy import workspace_entitlement, platform_feature_names, require_workspace_platform from langbot.pkg.utils import importutil +from langbot.pkg.api.application.transport import ApplicationInputError from ... import group @@ -44,9 +44,10 @@ class AdaptersRouterGroup(group.RouterGroup): if not isinstance(data, dict): return self.http_status(400, -1, 'invalid_input') try: - result = await slack_setup.start( + result = await self.call_application( + 'platform.setup.slack.start', request_context, - **{ + { key: data[key] for key in ( 'bot_uuid', @@ -61,8 +62,10 @@ class AdaptersRouterGroup(group.RouterGroup): }, ) return self.success(data=result) - except (SlackSetupError, TypeError) as exc: - return self.http_status(400, -1, str(exc) if isinstance(exc, SlackSetupError) else 'invalid_input') + except CredentialSetupError as exc: + return self.http_status(exc.status_code, -1, str(exc)) + except ApplicationInputError: + return self.http_status(400, -1, 'invalid_input') @self.route( '/slack/setup/', @@ -72,15 +75,17 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def slack_status(session_id: str, request_context: RequestContext): try: - slack_setup.owned(request_context, session_id) + action = 'cancel' if quart.request.method == 'DELETE' else 'status' + result = await self.call_application( + 'platform.setup.slack.' + action, request_context, {'session_id': session_id} + ) if quart.request.method == 'DELETE': - slack_setup.discard(session_id) - return self.success(data={}) - response = await quart.make_response(self.success(data=slack_setup.status(request_context, session_id))) + return self.success(data=result) + response = await quart.make_response(self.success(data=result)) response.headers['Cache-Control'] = 'no-store' return response - except SlackSetupError: - return self.http_status(404, -1, 'session_not_found') + except CredentialSetupError as exc: + return self.http_status(exc.status_code, -1, str(exc)) @self.route('/slack/setup/callback', methods=['GET'], auth_type=group.AuthType.NONE) async def slack_callback(): @@ -108,24 +113,16 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('', methods=['GET']) async def _(request_context: RequestContext) -> str: - snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid) - adapters = self.ap.platform_mgr.get_available_adapters_info() - if snapshot is not None: - adapters = [ - a - for a in adapters - if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name'])) - ] - return self.success(data={'adapters': adapters}) + return self.success(data={'adapters': await self.call_application('adapter.list', request_context, {})}) @self.route('/', methods=['GET']) async def _(adapter_name: str, request_context: RequestContext) -> str: - await require_workspace_platform(self.ap, request_context.workspace_uuid, adapter_name) - adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name) - - if adapter_info is None: + try: + adapter_info = await self.call_application( + 'adapter.get', request_context, {'adapter_name': adapter_name} + ) + except FileNotFoundError: return self.http_status(404, -1, 'adapter not found') - return self.success(data={'adapter': adapter_info}) @self.route('//icon', methods=['GET'], auth_type=group.AuthType.NONE) @@ -169,7 +166,7 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('/lark/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE) async def lark_start(request_context: RequestContext) -> str: try: - result = await credential_setup.lark_start(request_context=request_context) + result = await self.call_application('platform.setup.lark.start', request_context, {}) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -181,7 +178,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def lark_status(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.lark_status(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.lark.status', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -193,7 +192,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def lark_cancel(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.lark_cancel(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.lark.cancel', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -201,7 +202,7 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('/weixin/login', methods=['POST'], permission=Permission.RESOURCE_MANAGE) async def weixin_start(request_context: RequestContext) -> str: try: - result = await credential_setup.weixin_start(request_context=request_context) + result = await self.call_application('platform.setup.weixin.start', request_context, {}) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -213,7 +214,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def weixin_status(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.weixin_status(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.weixin.status', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -225,7 +228,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def weixin_cancel(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.weixin_cancel(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.weixin.cancel', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -233,7 +238,7 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('/dingtalk/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE) async def dingtalk_start(request_context: RequestContext) -> str: try: - result = await credential_setup.dingtalk_start(request_context=request_context) + result = await self.call_application('platform.setup.dingtalk.start', request_context, {}) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -245,7 +250,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def dingtalk_status(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.dingtalk_status(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.dingtalk.status', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -257,7 +264,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def dingtalk_cancel(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.dingtalk_cancel(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.dingtalk.cancel', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -265,7 +274,7 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('/wecombot/create-bot', methods=['POST'], permission=Permission.RESOURCE_MANAGE) async def wecombot_start(request_context: RequestContext) -> str: try: - result = await credential_setup.wecombot_start(request_context=request_context) + result = await self.call_application('platform.setup.wecombot.start', request_context, {}) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -277,7 +286,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def wecombot_status(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.wecombot_status(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.wecombot.status', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -289,7 +300,9 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def wecombot_cancel(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.wecombot_cancel(session_id=session_id, request_context=request_context) + result = await self.call_application( + 'platform.setup.wecombot.cancel', request_context, {'session_id': session_id} + ) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -297,7 +310,7 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('/qqofficial/bind', methods=['POST'], permission=Permission.RESOURCE_MANAGE) async def qqofficial_start(request_context: RequestContext) -> str: try: - result = await credential_setup.qqofficial_start(request_context=request_context) + result = await self.call_application('platform.setup.qqofficial.start', request_context, {}) return self.success(data=result) except CredentialSetupError as exc: return self.http_status(exc.status_code, -1, str(exc)) @@ -309,8 +322,8 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def qqofficial_status(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.qqofficial_status( - session_id=session_id, request_context=request_context + result = await self.call_application( + 'platform.setup.qqofficial.status', request_context, {'session_id': session_id} ) return self.success(data=result) except CredentialSetupError as exc: @@ -323,8 +336,8 @@ class AdaptersRouterGroup(group.RouterGroup): ) async def qqofficial_cancel(session_id: str, request_context: RequestContext) -> str: try: - result = await credential_setup.qqofficial_cancel( - session_id=session_id, request_context=request_context + result = await self.call_application( + 'platform.setup.qqofficial.cancel', request_context, {'session_id': session_id} ) return self.success(data=result) except CredentialSetupError as exc: diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index f7824dc93..9478839ec 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -28,10 +28,7 @@ class BotsRouterGroup(group.RouterGroup): 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, - ) + 'bots': await self.call_application('bot.list', request_context, {'include_secret': include_secret}) } ) @@ -46,7 +43,7 @@ class BotsRouterGroup(group.RouterGroup): if not isinstance(json_data, dict): return self.http_status(400, 'invalid_bot_config', 'Bot configuration must be an object') try: - bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data) + bot_uuid = await self.call_application('bot.create', request_context, {'bot_data': json_data}) except BotApplyError as exc: return self._apply_error_response(exc) except ValueError as exc: @@ -61,10 +58,8 @@ class BotsRouterGroup(group.RouterGroup): ) 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, + bot = await self.call_application( + 'bot.runtime.get', request_context, {'bot_uuid': bot_uuid, 'include_secret': include_secret} ) if bot is None: return self.http_status(404, -1, 'bot not found') @@ -82,13 +77,15 @@ class BotsRouterGroup(group.RouterGroup): if not isinstance(json_data, dict): return self.http_status(400, 'invalid_bot_config', 'Bot configuration must be an object') try: - await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data) + await self.call_application( + 'bot.update', request_context, {'bot_uuid': bot_uuid, 'bot_data': json_data} + ) except BotApplyError as exc: return self._apply_error_response(exc) except ValueError as exc: return self.http_status(400, 'invalid_bot_config', bot_error_message(exc, json_data)) else: - await self.ap.bot_service.delete_bot(request_context, bot_uuid) + await self.call_application('bot.delete', request_context, {'bot_uuid': bot_uuid}) return self.success() @self.route( @@ -101,8 +98,8 @@ class BotsRouterGroup(group.RouterGroup): 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( - request_context, bot_uuid, from_index, max_count + logs, total_count = await self.call_application( + 'bot.logs', request_context, {'bot_uuid': bot_uuid, 'from_index': from_index, 'max_count': max_count} ) return self.success(data={'logs': logs, 'total_count': total_count}) @@ -113,20 +110,25 @@ class BotsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(bot_uuid: str, request_context: RequestContext) -> str: - return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid)) + return self.success( + data=await self.call_application('bot.routes.status', request_context, {'bot_uuid': bot_uuid}) + ) async def _dry_run_event_route(bot_uuid: str, request_context: RequestContext) -> str: json_data = await quart.request.json if not isinstance(json_data, dict): return self.http_status(400, -1, 'invalid request body') - result = await self.ap.bot_service.dry_run_event_route( + result = await self.call_application( + 'bot.routes.preview', request_context, - bot_uuid=bot_uuid, - event_type=json_data.get('event_type'), - event_data=json_data.get('event_data', json_data.get('payload')), - context=json_data.get('context'), - event_bindings=json_data.get('event_bindings'), + { + 'bot_uuid': bot_uuid, + 'event_type': json_data.get('event_type'), + 'event_data': json_data.get('event_data', json_data.get('payload')), + 'event_context': json_data.get('context'), + 'event_bindings': json_data.get('event_bindings'), + }, ) return self.success(data=result) @@ -166,12 +168,15 @@ 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"') - await self.ap.bot_service.send_message( + await self.call_application( + 'bot.message.send', request_context, - bot_uuid, - target_type, - target_id, - message_chain_data, + { + 'bot_uuid': bot_uuid, + 'target_type': target_type, + 'target_id': target_id, + 'message_chain_data': message_chain_data, + }, ) return self.success(data={'sent': True}) @@ -184,10 +189,10 @@ class BotsRouterGroup(group.RouterGroup): async def _(bot_uuid: str, request_context: RequestContext) -> str: json_data = await quart.request.get_json(silent=True) or {} try: - result = await self.ap.bot_service.send_http_bot_test_message( + result = await self.call_application( + 'bot.message.test', request_context, - bot_uuid, - str(json_data.get('message') or ''), + {'bot_uuid': bot_uuid, 'message': str(json_data.get('message') or '')}, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -200,7 +205,7 @@ class BotsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(bot_uuid: str, request_context: RequestContext) -> str: - admins = await self.ap.bot_service.get_bot_admins(request_context, bot_uuid) + admins = await self.call_application('bot.admins.list', request_context, {'bot_uuid': bot_uuid}) return self.success(data={'admins': admins}) @self.route( @@ -216,8 +221,10 @@ class BotsRouterGroup(group.RouterGroup): 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 + admin_id = await self.call_application( + 'bot.admins.add', + request_context, + {'bot_uuid': bot_uuid, 'launcher_type': launcher_type, 'launcher_id': launcher_id}, ) return self.success(data={'id': admin_id}) except IntegrityError as e: @@ -230,7 +237,9 @@ class BotsRouterGroup(group.RouterGroup): 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) + await self.call_application( + 'bot.admins.delete', request_context, {'bot_uuid': bot_uuid, 'admin_id': admin_id} + ) return self.success() @self.route( @@ -241,7 +250,9 @@ class BotsRouterGroup(group.RouterGroup): ) async def _(bot_uuid: str, request_context: RequestContext) -> str: try: - result = await self.ap.bot_service.preview_group_filter_migration(request_context, bot_uuid) + result = await self.call_application( + 'bot.group_filter.preview', request_context, {'bot_uuid': bot_uuid} + ) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data=result) @@ -256,8 +267,10 @@ class BotsRouterGroup(group.RouterGroup): json_data = await quart.request.get_json(silent=True) or {} disable_pipeline_at_bot = bool(json_data.get('disable_pipeline_at_bot', True)) try: - result = await self.ap.bot_service.apply_group_filter_migration( - request_context, bot_uuid, disable_pipeline_at_bot=disable_pipeline_at_bot + result = await self.call_application( + 'bot.group_filter.apply', + request_context, + {'bot_uuid': bot_uuid, 'disable_pipeline_at_bot': disable_pipeline_at_bot}, ) except BotApplyError as exc: return self._apply_error_response(exc) diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index a0c2fa4dc..3254b25b0 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -1,38 +1,20 @@ from __future__ import annotations -from .....i18n import t - import asyncio import base64 -import collections.abc import quart -import httpx -import uuid import os -import zipfile -from urllib.parse import urlparse -import sqlalchemy -from .....core import taskmgr -from .....entity.persistence import plugin as persistence_plugin from ...authz import Permission from ...context import ExecutionContext, RequestContext from .....operation_trace import service as settings_service from .. import group from .....workspace.errors import WorkspaceNotFoundError from langbot_plugin.entities.io.errors import ActionCallError -from .....plugin.github import validate_github_plugin_install_info -from .....plugin.archive import inspect_plugin_archive_metadata -from .....utils import httpclient -from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from .....plugin.management import ( - count_plugin_components, - parse_github_repo_url, normalize_plugin_asset_path as _normalize_plugin_asset_path, - redact_plugin_secrets, - restore_plugin_secret_placeholders, ) @@ -48,16 +30,6 @@ except Exception: pass -def _is_plugin_not_installed(exc: BaseException) -> bool: - """True when the connector reports a plugin absent from the resolved Workspace. - - The connector raises a plain ``ValueError`` for this case, so resource routes - answer 404 instead of surfacing a 500 the WebUI would have to swallow. - """ - - return 'is not installed in this workspace' in str(exc).lower() - - def _get_request_origin() -> str: """Return the public request origin, respecting reverse-proxy headers.""" forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip() @@ -70,52 +42,6 @@ def _get_request_origin() -> str: @group.group_class('plugins', '/api/v1/plugins') class PluginsRouterGroup(group.RouterGroup): - _count_plugin_components = staticmethod(count_plugin_components) - _parse_github_repo_url = staticmethod(parse_github_repo_url) - - 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) - from ...service.workspace_limits import workspace_limits - - limitation = await workspace_limits(self.ap, request_context.workspace_uuid) - 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(request_context) - total_extensions = len(plugins) + len(mcp_servers) - if total_extensions >= max_extensions: - return self.http_status( - 400, - -1, - t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions), - ) - 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.""" - - persistence_mgr = getattr(self.ap, 'persistence_mgr', None) - tenant_scope = getattr(persistence_mgr, 'tenant_scope', None) - if callable(tenant_scope): - async with tenant_scope(execution_context.workspace_uuid): - await self.ap.plugin_connector.require_workspace_context(execution_context) - return await operation() - await self.ap.plugin_connector.require_workspace_context(execution_context) - return await operation() - async def _require_authenticated_plugin_runtime_context( self, request_context: RequestContext, @@ -144,22 +70,6 @@ class PluginsRouterGroup(group.RouterGroup): ) 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: @@ -177,10 +87,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - await self._require_authenticated_plugin_runtime_context(request_context) - plugins = await self.ap.plugin_connector.list_plugins() - - return self.success(data={'plugins': redact_plugin_secrets(plugins)}) + return self.success(data=await self.call_application('plugin.list', request_context, {})) @self.route( '/debug-info', @@ -189,30 +96,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(request_context: RequestContext) -> str: - """Get plugin debug information including debug URL and key""" - execution_context = await self._require_authenticated_plugin_runtime_context(request_context) - debug_info = await self.ap.plugin_connector.get_debug_info(execution_context) - - # Get debug URL from config - plugin_config = self.ap.instance_config.data.get('plugin', {}) - debug_url = plugin_config.get( - 'display_plugin_debug_url', - 'ws://localhost:5401/plugin/debug/ws', - ) - parsed_debug_url = urlparse(debug_url) - if parsed_debug_url.scheme in {'http', 'https'}: - debug_url = parsed_debug_url._replace( - scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws', - path=parsed_debug_url.path or '/plugin/debug/ws', - ).geturl() - - return self.success( - data={ - 'debug_url': debug_url, - 'plugin_debug_key': debug_info.get('plugin_debug_key', ''), - 'expires_at': debug_info.get('expires_at', ''), - } - ) + return self.success(data=await self.call_application('plugin.debug_info', request_context, {})) async def _space_access_token_for_workspace( request_context: RequestContext, @@ -472,29 +356,11 @@ class PluginsRouterGroup(group.RouterGroup): 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) - ctx = taskmgr.TaskContext.new() - ctx.metadata.update( - { - 'plugin_name': f'{author}/{plugin_name}', - 'install_source': 'marketplace', - 'operation': 'upgrade', - 'progress_percent': 3, - } + return self.success( + data=await self.call_application( + 'plugin.upgrade', request_context, {'author': author, 'plugin_name': plugin_name} + ) ) - ctx.set_current_action('checking for latest version') - wrapper = self.ap.task_mgr.create_user_task( - 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( '//', @@ -503,11 +369,13 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: - await self._require_authenticated_plugin_runtime_context(request_context) - plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) - if plugin is None: + try: + result = await self.call_application( + 'plugin.get', request_context, {'author': author, 'plugin_name': plugin_name} + ) + except WorkspaceNotFoundError: return self.http_status(404, -1, 'plugin not found') - return self.success(data={'plugin': redact_plugin_secrets(plugin)}) + return self.success(data=result) @self.route( '//', @@ -516,26 +384,18 @@ class PluginsRouterGroup(group.RouterGroup): 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=await self.call_application( + 'plugin.delete', + request_context, + { + 'author': author, + 'plugin_name': plugin_name, + 'delete_data': delete_data, + }, + ) ) - return self.success(data={'task_id': wrapper.id}) @self.route( '///config', @@ -544,18 +404,13 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self._require_authenticated_plugin_runtime_context(request_context) - plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) - if plugin is None: + try: + result = await self.call_application( + 'plugin.config.get', request_context, {'author': author, 'plugin_name': plugin_name} + ) + except WorkspaceNotFoundError: return self.http_status(404, -1, 'plugin not found') - - config = await self._get_stored_plugin_config( - request_context, - author, - plugin_name, - plugin, - ) - return self.success(data={'config': redact_plugin_secrets(config)}) + return self.success(data=result) @self.route( '///config', @@ -564,38 +419,22 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self._require_authenticated_plugin_runtime_context(request_context) - plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) - if plugin is None: - return self.http_status(404, -1, 'plugin not found') - current_config = await self._get_stored_plugin_config( - request_context, - author, - plugin_name, - plugin, - ) try: - incoming_config = await quart.request.json - except Exception: - incoming_config = None - try: - config = restore_plugin_secret_placeholders( - incoming_config, - current_config, + incoming_config = await quart.request.get_json(silent=True) + result = await self.call_application( + 'plugin.config.update', + request_context, + { + 'author': author, + 'plugin_name': plugin_name, + 'config': incoming_config, + }, ) + except WorkspaceNotFoundError: + return self.http_status(404, -1, 'plugin not found') except ValueError as exc: return self.http_status(400, -1, str(exc)) - await self._require_authenticated_plugin_runtime_context(request_context) - await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config) - - # Record which plugin was reconfigured and which keys actually moved. - # The diff is computed over the *requested* payload (secrets restored - # from the stored config) so a masked ``***`` round-trip never shows - # up as a change; sensitive keys are redacted by ``changed_fields``. - config_changes = settings_service.changed_fields( - current_config if isinstance(current_config, dict) else {}, - config if isinstance(config, dict) else {}, - ) + config_changes = result['changes'] rule = settings_service.ACTION_RULES_BY_ACTION.get('plugin_config') quart.g.operation_log_resource_id = f'{author}/{plugin_name}' quart.g.operation_log_changes = config_changes @@ -610,10 +449,18 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self._require_authenticated_plugin_runtime_context(request_context) 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}) + return self.success( + data=await self.call_application( + 'plugin.readme', + request_context, + { + 'author': author, + 'plugin_name': plugin_name, + 'language': language, + }, + ) + ) @self.route( '///logs', @@ -622,14 +469,23 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.AUDIT_VIEW, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response: - await self._require_authenticated_plugin_runtime_context(request_context) try: limit = int(quart.request.args.get('limit', 200)) except (TypeError, ValueError): limit = 200 level = quart.request.args.get('level') or None - logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level) - return self.success(data={'logs': logs}) + return self.success( + data=await self.call_application( + 'plugin.logs', + request_context, + { + 'author': author, + 'plugin_name': plugin_name, + 'limit': limit, + 'level': level, + }, + ) + ) @self.route( '///authenticated-icon', @@ -642,20 +498,13 @@ class PluginsRouterGroup(group.RouterGroup): plugin_name: str, request_context: RequestContext, ) -> quart.Response: - await self._require_authenticated_plugin_runtime_context(request_context) try: - icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) - if not icon_data or not icon_data.get('plugin_icon_base64'): - return quart.Response('Icon not found', status=404) - except (ValueError, ActionCallError) as exc: - # A plugin that is not installed for this Workspace is a missing - # resource, not a server fault: the WebUI asks for icons by plugin - # identity and must be able to fall back silently. - if isinstance(exc, ActionCallError) or _is_plugin_not_installed(exc): - return quart.Response('Icon not found', status=404) - raise - icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64']) - return quart.Response(icon_bytes, mimetype=icon_data.get('mime_type', 'image/svg+xml')) + result = await self.call_application( + 'plugin.icon', request_context, {'author': author, 'plugin_name': plugin_name} + ) + except WorkspaceNotFoundError: + return quart.Response('Icon not found', status=404) + return quart.Response(result['content'], mimetype=result['mime_type']) @self.route( '///authenticated-assets/', @@ -669,18 +518,19 @@ class PluginsRouterGroup(group.RouterGroup): filepath: str, request_context: RequestContext, ) -> quart.Response: - await self._require_authenticated_plugin_runtime_context(request_context) - asset_path = _normalize_plugin_asset_path(filepath) - if asset_path is None: - return quart.Response('Asset not found', status=404) try: - asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) - except (ValueError, ActionCallError): + result = await self.call_application( + 'plugin.asset', + request_context, + { + 'author': author, + 'plugin_name': plugin_name, + 'filepath': filepath, + }, + ) + except WorkspaceNotFoundError: return quart.Response('Asset not found', status=404) - if not asset_data.get('asset_base64'): - return quart.Response('Asset not found', status=404) - asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64']) - return quart.Response(asset_bytes, mimetype=asset_data['mime_type']) + return quart.Response(result['content'], mimetype=result['mime_type']) @self.route( '///icon', @@ -743,27 +593,32 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: - """Forward a page API request to the plugin.""" - await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json if not isinstance(data, dict): return self.http_status(400, -1, 'invalid request body') - page_id = data.get('page_id', '') endpoint = data.get('endpoint', '') method = data.get('method', 'POST') - body = data.get('body') if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str): return self.http_status(400, -1, 'invalid page api request') if not endpoint.startswith('/') or '..' in endpoint: return self.http_status(400, -1, 'invalid endpoint') - - result = await self.ap.plugin_connector.handle_page_api( - author, plugin_name, page_id, endpoint, method.upper(), body - ) - if result.get('error'): - return self.http_status(400, -1, result['error']) - return self.success(data=result.get('data')) + try: + result = await self.call_application( + 'plugin.page_action', + request_context, + { + 'author': author, + 'plugin_name': plugin_name, + 'page_id': page_id, + 'endpoint': endpoint, + 'method': method, + 'body': data.get('body'), + }, + ) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data=result) @self.route( '/github/releases', @@ -772,78 +627,14 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - """Get releases from a GitHub repository URL""" - await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json - repo_url = data.get('repo_url', '') - - parsed_repo = self._parse_github_repo_url(repo_url) - if not parsed_repo: - return self.http_status(400, -1, 'Invalid GitHub repository URL') - - owner = parsed_repo['owner'] - repo = parsed_repo['repo'] - requested_ref = parsed_repo['ref'] - requested_subdir = parsed_repo['subdir'] - try: - if requested_ref: - return self.success( - data={ - 'releases': [ - { - 'id': 0, - 'tag_name': requested_ref, - 'name': requested_ref, - 'published_at': '', - 'prerelease': False, - 'draft': False, - 'source_type': 'branch', - 'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}', - } - ], - 'owner': owner, - 'repo': repo, - 'source_subdir': requested_subdir, - } - ) - - # Fetch releases from GitHub API - url = f'https://api.github.com/repos/{owner}/{repo}/releases' - async with httpx.AsyncClient( - trust_env=True, - follow_redirects=True, - timeout=10, - event_hooks=httpclient.httpx_response_limit_hooks(), - ) as client: - response = await client.get(url) - response.raise_for_status() - releases = await httpclient.parse_json_response(response) - - # Format releases data for frontend - formatted_releases = [] - for release in releases: - formatted_releases.append( - { - 'id': release['id'], - 'tag_name': release['tag_name'], - 'name': release['name'], - 'published_at': release['published_at'], - 'prerelease': release['prerelease'], - 'draft': release['draft'], - } - ) - - return self.success( - data={ - 'releases': formatted_releases, - 'owner': owner, - 'repo': repo, - 'source_subdir': requested_subdir, - } + result = await self.call_application( + 'plugin.github.releases', request_context, {'repo_url': data.get('repo_url', '')} ) - except httpx.RequestError: - raise + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data=result) @self.route( '/github/release-assets', @@ -852,58 +643,15 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - """Get assets from a specific GitHub release""" - await self._require_authenticated_plugin_runtime_context(request_context) data = await quart.request.json - owner = data.get('owner', '') - repo = data.get('repo', '') - release_id = data.get('release_id', '') - - if not all([owner, repo, release_id]): + payload = {key: data.get(key, '') for key in ('owner', 'repo', 'release_id')} + if not all(payload.values()): return self.http_status(400, -1, 'Missing required parameters') - try: - # Fetch release assets from GitHub API - url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}' - async with httpx.AsyncClient( - trust_env=True, - follow_redirects=True, - timeout=10, - event_hooks=httpclient.httpx_response_limit_hooks(), - ) as client: - response = await client.get( - url, - ) - response.raise_for_status() - release = await httpclient.parse_json_response(response) - - # Format assets data for frontend - formatted_assets = [] - for asset in release.get('assets', []): - formatted_assets.append( - { - 'id': asset['id'], - 'name': asset['name'], - 'size': asset['size'], - 'download_url': asset['browser_download_url'], - 'content_type': asset['content_type'], - } - ) - - # add zipball as a downloadable asset - # formatted_assets.append( - # { - # "id": 0, - # "name": "Source code (zip)", - # "size": -1, - # "download_url": release["zipball_url"], - # "content_type": "application/zip", - # } - # ) - - return self.success(data={'assets': formatted_assets}) - except httpx.RequestError: - raise + result = await self.call_application('plugin.github.assets', request_context, payload) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data=result) @self.route( '/install/github', @@ -912,59 +660,20 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(request_context: RequestContext) -> str: - """Install plugin from GitHub release asset""" - limit_error = await self._check_extensions_limit(request_context) - if limit_error is not None: - return limit_error - data = await quart.request.json or {} try: - install_info = validate_github_plugin_install_info( + result = await self.call_application( + 'plugin.install.github', + request_context, { - 'asset_url': data.get('asset_url'), - 'asset_id': data.get('asset_id'), - 'release_id': data.get('release_id'), - 'owner': data.get('owner'), - 'repo': data.get('repo'), - 'release_tag': data.get('release_tag'), - 'github_url': f'https://github.com/{data.get("owner", "")}/{data.get("repo", "")}', - } + key: data.get(key) + for key in ('owner', 'repo', 'release_tag', 'asset_url', 'asset_id', 'release_id') + }, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) - - owner = install_info['owner'] - repo = install_info['repo'] - release_tag = install_info['release_tag'] - # Name the installed extension in the trace. GitHub installs carry - # owner/repo in the body, which the audit identity resolver can also - # read, but publishing it here keeps the trace correct regardless of - # how the request was encoded. - quart.g.operation_log_resource_id = f'{owner}/{repo}' - - 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' - - wrapper = self.ap.task_mgr.create_user_task( - 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}) + quart.g.operation_log_resource_id = f'{data["owner"]}/{data["repo"]}' + return self.success(data=result) @self.route( '/install/marketplace', @@ -973,37 +682,13 @@ class PluginsRouterGroup(group.RouterGroup): 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 - data = await quart.request.json - - plugin_author = data.get('plugin_author', '') - plugin_name = data.get('plugin_name', '') - quart.g.operation_log_resource_id = f'{plugin_author}/{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._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}) + try: + result = await self.call_application('plugin.install.marketplace', request_context, data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + quart.g.operation_log_resource_id = f'{data["plugin_author"]}/{data["plugin_name"]}' + return self.success(data=result) @self.route( '/install/local', @@ -1012,47 +697,25 @@ class PluginsRouterGroup(group.RouterGroup): 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 - file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') - - file_bytes = file.read() form = await quart.request.form - administrator_force = form.get('administrator_force', '').strip().lower() == 'true' - # A local upload is multipart with no JSON body, so the archive - # filename is the only identity available for the trace. - quart.g.operation_log_resource_id = file.filename or 'local plugin' - execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) - - data = { - 'plugin_file': file_bytes, - 'administrator_force': administrator_force, - } - - ctx = taskmgr.TaskContext.new() - ctx.metadata['plugin_name'] = file.filename or 'local plugin' - ctx.metadata['install_source'] = 'local' - wrapper = self.ap.task_mgr.create_user_task( - 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}) + filename = file.filename or 'local plugin' + try: + result = await self.call_application( + 'plugin.install.local', + request_context, + { + 'content': file.read(), + 'filename': filename, + 'administrator_force': form.get('administrator_force', '').strip().lower() == 'true', + }, + ) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + quart.g.operation_log_resource_id = filename + return self.success(data=result) @self.route( '/install/local/preview', @@ -1061,38 +724,21 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(request_context: RequestContext) -> str: - await self._require_authenticated_plugin_runtime_context(request_context) file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') - - file_bytes = file.read() try: - manifest, requirements, names = await asyncio.to_thread( - inspect_plugin_archive_metadata, - file_bytes, - ) - spec = manifest.get('spec') or {} - components = spec.get('components') or {} - component_counts = self._count_plugin_components(components, names) - component_types = list(component_counts.keys()) - - return self.success( - data={ + result = await self.call_application( + 'plugin.install.local.preview', + request_context, + { + 'content': file.read(), 'filename': file.filename or 'local plugin', - 'size': len(file_bytes), - 'manifest': manifest, - 'metadata': manifest.get('metadata') or {}, - 'component_types': component_types, - 'component_counts': component_counts, - 'requirements': requirements, - 'file_count': len(names), - } + }, ) - except (zipfile.BadZipFile, ValueError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc) or 'invalid .lbpkg file') - except Exception: - raise + return self.success(data=result) @self.route( '/config-files', @@ -1101,31 +747,23 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(request_context: RequestContext) -> str: - """Upload a file for plugin configuration""" - await self._require_authenticated_plugin_runtime_context(request_context) file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') - - # Check file size (10MB limit) - MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB file_bytes = file.read() - if len(file_bytes) > MAX_FILE_SIZE: + if len(file_bytes) > 10 * 1024 * 1024: return self.http_status(400, -1, 'file size exceeds 10MB limit') - - original_filename = file.filename or 'config.bin' - _, ext = os.path.splitext(original_filename) - 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=await self.call_application( + 'plugin.config_file.upload', + request_context, + { + 'content': file_bytes, + 'filename': file.filename or 'config.bin', + }, + ) ) - return self.success(data={'file_key': file_key}) - @self.route( '/config-files/', methods=['DELETE'], @@ -1133,17 +771,10 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) async def _(file_key: str, request_context: RequestContext) -> str: - """Delete a plugin configuration file""" - await self._require_authenticated_plugin_runtime_context(request_context) - if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'): - return self.http_status(400, -1, 'invalid file key') - try: - await self.ap.storage_mgr.delete_scoped_object_key( - request_context, - file_key, - expected_owner_type='plugin_config', + result = await self.call_application( + 'plugin.config_file.delete', request_context, {'file_key': file_key} ) - return self.success(data={'deleted': True}) - except Exception: - raise + except ValueError: + return self.http_status(400, -1, 'invalid file key') + return self.success(data=result) diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py index 678550b69..1e80a5a55 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -16,7 +16,9 @@ class LLMModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/_/starred', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_starred(request_context: RequestContext) -> str: - return self.success(data={'uuid': await self.ap.llm_model_service.get_starred_model(request_context)}) + return self.success( + data={'uuid': await self.call_application('model.llm.starred.get', request_context, {})} + ) @self.route('/_/starred', methods=['PUT'], permission=Permission.PROVIDER_SECRET_MANAGE) async def set_starred(request_context: RequestContext) -> str: @@ -25,14 +27,16 @@ class LLMModelsRouterGroup(group.RouterGroup): if model_uuid is not None and not isinstance(model_uuid, str): return self.http_status(400, -1, 'uuid must be a string or null') try: - await self.ap.llm_model_service.set_starred_model(request_context, model_uuid) + await self.call_application('model.llm.starred.set', request_context, {'model_uuid': model_uuid}) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data={}) @self.route('/_/default', methods=['GET'], permission=Permission.RESOURCE_MANAGE) async def get_default(request_context: RequestContext) -> str: - return self.success(data={'uuid': await self.ap.llm_model_service.get_default_model(request_context)}) + return self.success( + data={'uuid': await self.call_application('model.llm.default.get', request_context, {})} + ) @self.route( '', @@ -49,15 +53,14 @@ class LLMModelsRouterGroup(group.RouterGroup): if error: return self.http_status(400, -1, error) if provider_uuid: - models = await self.ap.llm_model_service.get_llm_models_by_provider( + models = await self.call_application( + 'model.llm.list_by_provider', request_context, - provider_uuid, - include_secret=include_secret, + {'provider_uuid': provider_uuid, 'include_secret': include_secret}, ) else: - models = await self.ap.llm_model_service.get_llm_models( - request_context, - include_secret=include_secret, + models = await self.call_application( + 'model.llm.list', request_context, {'include_secret': include_secret} ) return self.success(data={'models': models}) @@ -69,9 +72,8 @@ class LLMModelsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: try: - model_uuid = await self.ap.llm_model_service.create_llm_model( - request_context, - await quart.request.json, + model_uuid = await self.call_application( + 'model.llm.create', request_context, {'model_data': await quart.request.json} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -90,10 +92,8 @@ class LLMModelsRouterGroup(group.RouterGroup): ) if error: return self.http_status(400, -1, error) - model = await self.ap.llm_model_service.get_llm_model( - request_context, - model_uuid, - include_secret=include_secret, + model = await self.call_application( + 'model.llm.get', request_context, {'model_uuid': model_uuid, 'include_secret': include_secret} ) if model is None: return self.http_status(404, -1, 'model not found') @@ -108,14 +108,12 @@ class LLMModelsRouterGroup(group.RouterGroup): async def _(model_uuid: str, request_context: RequestContext) -> str: json_data = await quart.request.json try: - previous = await self.ap.llm_model_service.get_llm_model(request_context, model_uuid) + previous = await self.call_application('model.llm.get', request_context, {'model_uuid': model_uuid}) except Exception: previous = None try: - await self.ap.llm_model_service.update_llm_model( - request_context, - model_uuid, - json_data, + await self.call_application( + 'model.llm.update', request_context, {'model_uuid': model_uuid, 'model_data': json_data} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -137,7 +135,7 @@ class LLMModelsRouterGroup(group.RouterGroup): 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) + await self.call_application('model.llm.delete', request_context, {'model_uuid': model_uuid}) return self.success() @self.route( @@ -148,10 +146,10 @@ class LLMModelsRouterGroup(group.RouterGroup): ) async def _(model_uuid: str, request_context: RequestContext) -> str: try: - await self.ap.llm_model_service.test_llm_model( + await self.call_application( + 'model.llm.test', request_context, - model_uuid, - await quart.request.json, + {'model_uuid': model_uuid, 'model_data': await quart.request.json}, ) except provider_errors.RequesterError as exc: return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc)) @@ -176,15 +174,14 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): if error: return self.http_status(400, -1, error) if provider_uuid: - models = await self.ap.embedding_models_service.get_embedding_models_by_provider( + models = await self.call_application( + 'model.embedding.list_by_provider', request_context, - provider_uuid, - include_secret=include_secret, + {'provider_uuid': provider_uuid, 'include_secret': include_secret}, ) else: - models = await self.ap.embedding_models_service.get_embedding_models( - request_context, - include_secret=include_secret, + models = await self.call_application( + 'model.embedding.list', request_context, {'include_secret': include_secret} ) return self.success(data={'models': models}) @@ -196,9 +193,8 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: try: - model_uuid = await self.ap.embedding_models_service.create_embedding_model( - request_context, - await quart.request.json, + model_uuid = await self.call_application( + 'model.embedding.create', request_context, {'model_data': await quart.request.json} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -217,10 +213,8 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): ) if error: return self.http_status(400, -1, error) - model = await self.ap.embedding_models_service.get_embedding_model( - request_context, - model_uuid, - include_secret=include_secret, + model = await self.call_application( + 'model.embedding.get', request_context, {'model_uuid': model_uuid, 'include_secret': include_secret} ) if model is None: return self.http_status(404, -1, 'model not found') @@ -234,10 +228,10 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): ) async def _(model_uuid: str, request_context: RequestContext) -> str: try: - await self.ap.embedding_models_service.update_embedding_model( + await self.call_application( + 'model.embedding.update', request_context, - model_uuid, - await quart.request.json, + {'model_uuid': model_uuid, 'model_data': await quart.request.json}, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -250,7 +244,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): 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) + await self.call_application('model.embedding.delete', request_context, {'model_uuid': model_uuid}) return self.success() @self.route( @@ -261,10 +255,10 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): ) async def _(model_uuid: str, request_context: RequestContext) -> str: try: - await self.ap.embedding_models_service.test_embedding_model( + await self.call_application( + 'model.embedding.test', request_context, - model_uuid, - await quart.request.json, + {'model_uuid': model_uuid, 'model_data': await quart.request.json}, ) except provider_errors.RequesterError as exc: return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc)) @@ -289,15 +283,14 @@ class RerankModelsRouterGroup(group.RouterGroup): if error: return self.http_status(400, -1, error) if provider_uuid: - models = await self.ap.rerank_models_service.get_rerank_models_by_provider( + models = await self.call_application( + 'model.rerank.list_by_provider', request_context, - provider_uuid, - include_secret=include_secret, + {'provider_uuid': provider_uuid, 'include_secret': include_secret}, ) else: - models = await self.ap.rerank_models_service.get_rerank_models( - request_context, - include_secret=include_secret, + models = await self.call_application( + 'model.rerank.list', request_context, {'include_secret': include_secret} ) return self.success(data={'models': models}) @@ -309,9 +302,8 @@ class RerankModelsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: try: - model_uuid = await self.ap.rerank_models_service.create_rerank_model( - request_context, - await quart.request.json, + model_uuid = await self.call_application( + 'model.rerank.create', request_context, {'model_data': await quart.request.json} ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -330,10 +322,8 @@ class RerankModelsRouterGroup(group.RouterGroup): ) if error: return self.http_status(400, -1, error) - model = await self.ap.rerank_models_service.get_rerank_model( - request_context, - model_uuid, - include_secret=include_secret, + model = await self.call_application( + 'model.rerank.get', request_context, {'model_uuid': model_uuid, 'include_secret': include_secret} ) if model is None: return self.http_status(404, -1, 'model not found') @@ -347,10 +337,10 @@ class RerankModelsRouterGroup(group.RouterGroup): ) async def _(model_uuid: str, request_context: RequestContext) -> str: try: - await self.ap.rerank_models_service.update_rerank_model( + await self.call_application( + 'model.rerank.update', request_context, - model_uuid, - await quart.request.json, + {'model_uuid': model_uuid, 'model_data': await quart.request.json}, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -363,7 +353,7 @@ class RerankModelsRouterGroup(group.RouterGroup): 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) + await self.call_application('model.rerank.delete', request_context, {'model_uuid': model_uuid}) return self.success() @self.route( @@ -374,10 +364,10 @@ class RerankModelsRouterGroup(group.RouterGroup): ) async def _(model_uuid: str, request_context: RequestContext) -> str: try: - await self.ap.rerank_models_service.test_rerank_model( + await self.call_application( + 'model.rerank.test', request_context, - model_uuid, - await quart.request.json, + {'model_uuid': model_uuid, 'model_data': await quart.request.json}, ) except provider_errors.RequesterError as exc: return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc)) diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py index 0a7302b58..b824347a8 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py @@ -20,7 +20,9 @@ class ModelProvidersRouterGroup(group.RouterGroup): async def codex_status(provider_uuid: str, request_context: RequestContext): try: return self.success( - data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid) + data=await self.call_application( + 'provider.codex.status', request_context, {'provider': provider_uuid} + ) ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -34,7 +36,9 @@ class ModelProvidersRouterGroup(group.RouterGroup): async def codex_device(provider_uuid: str, request_context: RequestContext): try: return self.success( - data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid) + data=await self.call_application( + 'provider.codex.start', request_context, {'provider': provider_uuid} + ) ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -51,8 +55,10 @@ class ModelProvidersRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'JSON object required') try: return self.success( - data=await self.ap.provider_service.codex_auth.poll( - request_context, provider_uuid, body.get('authorization_id') + data=await self.call_application( + 'provider.codex.poll', + request_context, + {'provider': provider_uuid, 'authorization_id': body.get('authorization_id')}, ) ) except ValueError as exc: @@ -66,7 +72,7 @@ class ModelProvidersRouterGroup(group.RouterGroup): ) async def codex_disconnect(provider_uuid: str, request_context: RequestContext): try: - await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid) + await self.call_application('provider.codex.disconnect', request_context, {'provider': provider_uuid}) return self.success() except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -79,7 +85,11 @@ class ModelProvidersRouterGroup(group.RouterGroup): ) async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext): try: - await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id) + await self.call_application( + 'provider.codex.cancel', + request_context, + {'provider': provider_uuid, 'authorization_id': authorization_id}, + ) return self.success() except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -97,12 +107,13 @@ class ModelProvidersRouterGroup(group.RouterGroup): ) if error: return self.http_status(400, -1, error) - providers = await self.ap.provider_service.get_providers( - request_context, - include_secret=include_secret, + providers = await self.call_application( + 'provider.list', request_context, {'include_secret': include_secret} ) for provider in providers: - counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid']) + counts = await self.call_application( + 'provider.models.count', request_context, {'provider_uuid': provider['uuid']} + ) provider['llm_count'] = counts['llm_count'] provider['embedding_count'] = counts['embedding_count'] provider['rerank_count'] = counts['rerank_count'] @@ -117,7 +128,9 @@ class ModelProvidersRouterGroup(group.RouterGroup): 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) + provider_uuid = await self.call_application( + 'provider.create', request_context, {'provider_data': json_data} + ) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data={'uuid': provider_uuid}) @@ -135,14 +148,14 @@ class ModelProvidersRouterGroup(group.RouterGroup): ) if error: return self.http_status(400, -1, error) - provider = await self.ap.provider_service.get_provider( - request_context, - provider_uuid, - include_secret=include_secret, + provider = await self.call_application( + 'provider.get', request_context, {'provider_uuid': provider_uuid, 'include_secret': include_secret} ) 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) + counts = await self.call_application( + 'provider.models.count', request_context, {'provider_uuid': provider_uuid} + ) provider['llm_count'] = counts['llm_count'] provider['embedding_count'] = counts['embedding_count'] provider['rerank_count'] = counts['rerank_count'] @@ -157,15 +170,15 @@ class ModelProvidersRouterGroup(group.RouterGroup): async def _(provider_uuid: str, request_context: RequestContext) -> str: json_data = await quart.request.json try: - previous = await self.ap.provider_service.get_provider( - request_context, - provider_uuid, - include_secret=True, + previous = await self.call_application( + 'provider.get', request_context, {'provider_uuid': provider_uuid, 'include_secret': True} ) except Exception: previous = None try: - await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data) + await self.call_application( + 'provider.update', request_context, {'provider_uuid': provider_uuid, 'provider_data': json_data} + ) except ValueError as exc: return self.http_status(400, -1, str(exc)) # Record which provider was reconfigured and which fields moved; @@ -193,11 +206,13 @@ class ModelProvidersRouterGroup(group.RouterGroup): if cascade_values: if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'): return self.http_status(400, -1, 'cascade must be a single true or false value') - await self.ap.provider_service.delete_provider( - request_context, provider_uuid, cascade=cascade_values[0] == 'true' + await self.call_application( + 'provider.delete', + request_context, + {'provider_uuid': provider_uuid, 'cascade': cascade_values[0] == 'true'}, ) else: - await self.ap.provider_service.delete_provider(request_context, provider_uuid) + await self.call_application('provider.delete', request_context, {'provider_uuid': provider_uuid}) return self.success() except ValueError as e: return self.http_status(400, -1, str(e)) @@ -211,7 +226,9 @@ class ModelProvidersRouterGroup(group.RouterGroup): 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(request_context, provider_uuid, model_type) + result = await self.call_application( + 'provider.models.scan', request_context, {'provider_uuid': provider_uuid, 'model_type': model_type} + ) return self.success(data=result) except ValueError as e: return self.http_status(400, -1, str(e)) diff --git a/src/langbot/pkg/api/http/controller/groups/provider/requesters.py b/src/langbot/pkg/api/http/controller/groups/provider/requesters.py index 268ed11d5..a7816ff60 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/requesters.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/requesters.py @@ -2,6 +2,7 @@ import quart import mimetypes from ... import group +from ....context import RequestContext from langbot.pkg.utils import importutil @@ -9,15 +10,23 @@ from langbot.pkg.utils import importutil class RequestersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET']) - async def _() -> quart.Response: + async def _(request_context: RequestContext) -> quart.Response: model_type = quart.request.args.get('type', '') - return self.success(data={'requesters': self.ap.model_mgr.get_available_requesters_info(model_type)}) + return self.success( + data={ + 'requesters': await self.call_application( + 'requester.list', request_context, {'model_type': model_type} + ) + } + ) @self.route('/', methods=['GET']) - async def _(requester_name: str) -> quart.Response: - requester_info = self.ap.model_mgr.get_available_requester_info_by_name(requester_name) - - if requester_info is None: + async def _(requester_name: str, request_context: RequestContext) -> quart.Response: + try: + requester_info = await self.call_application( + 'requester.get', request_context, {'requester_name': requester_name} + ) + except FileNotFoundError: return self.http_status(404, -1, 'requester not found') return self.success(data={'requester': requester_info}) diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index 56192506a..efc3546e0 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -20,7 +20,7 @@ class MCPRouterGroup(group.RouterGroup): 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) + servers = await self.call_application('mcp.server.list', request_context, {'contain_runtime_info': True}) return self.success(data={'servers': servers}) @self.route( @@ -32,7 +32,7 @@ class MCPRouterGroup(group.RouterGroup): 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) + server_uuid = await self.call_application('mcp.server.create', request_context, {'server_data': data}) except MCPStdioDisabledError as exc: return self.http_status(403, exc.code, str(exc)) except ValueError as exc: @@ -47,7 +47,7 @@ class MCPRouterGroup(group.RouterGroup): ) 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) + server_data = await self.call_application('mcp.server.get', request_context, {'server_name': server_name}) if server_data is None: return self.http_status(404, -1, 'Server not found') return self.success(data={'server': server_data}) @@ -60,7 +60,7 @@ class MCPRouterGroup(group.RouterGroup): ) 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) + server_data = await self.call_application('mcp.server.get', request_context, {'server_name': server_name}) if server_data is None: return self.http_status(404, -1, 'Server not found') # Name the MCP server in the trace, and for an update record which @@ -70,7 +70,9 @@ class MCPRouterGroup(group.RouterGroup): if quart.request.method == 'PUT': data = await quart.request.json try: - await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data) + await self.call_application( + 'mcp.server.update', request_context, {'server_uuid': server_data['uuid'], 'server_data': data} + ) except MCPStdioDisabledError as exc: return self.http_status(403, exc.code, str(exc)) except ValueError as exc: @@ -85,7 +87,7 @@ class MCPRouterGroup(group.RouterGroup): if rule is not None and changes: quart.g.operation_log_summary = settings_service.build_summary(rule, changes) else: - await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid']) + await self.call_application('mcp.server.delete', request_context, {'server_uuid': server_data['uuid']}) return self.success() @self.route( @@ -99,10 +101,8 @@ class MCPRouterGroup(group.RouterGroup): server_name = unquote(server_name) server_data = await quart.request.json try: - task_id = await self.ap.mcp_service.test_mcp_server( - request_context, - server_name=server_name, - server_data=server_data, + task_id = await self.call_application( + 'mcp.server.test', request_context, {'server_name': server_name, 'server_data': server_data} ) except MCPStdioDisabledError as exc: return self.http_status(403, exc.code, str(exc)) @@ -117,9 +117,13 @@ class MCPRouterGroup(group.RouterGroup): async def _(server_name: str, request_context: RequestContext) -> str: """Get resources from an MCP server""" server_name = unquote(server_name) - 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) + resources = await self.call_application('mcp.resource.list', request_context, {'server_name': server_name}) + templates = await self.call_application( + 'mcp.resource.templates', request_context, {'server_name': server_name} + ) + runtime_info = await self.call_application( + 'mcp.server.runtime', request_context, {'server_name': server_name} + ) return self.success( data={ 'resources': resources, @@ -137,7 +141,9 @@ class MCPRouterGroup(group.RouterGroup): async def _(server_name: str, request_context: RequestContext) -> str: """Get resource templates from an MCP server""" server_name = unquote(server_name) - templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name) + templates = await self.call_application( + 'mcp.resource.templates', request_context, {'server_name': server_name} + ) return self.success(data={'resource_templates': templates}) @self.route( @@ -155,11 +161,8 @@ 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( - request_context, - server_name, - limit=limit, - level=level, + logs = await self.call_application( + 'mcp.server.logs', request_context, {'server_name': server_name, 'limit': limit, 'level': level} ) return self.success(data={'logs': logs}) @@ -176,11 +179,14 @@ class MCPRouterGroup(group.RouterGroup): uri = data.get('uri') if not uri: return self.http_status(400, -1, 'URI is required') - envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope( + envelope = await self.call_application( + 'mcp.resource.read', request_context, - server_name, - uri, - max_bytes=data.get('max_bytes'), - include_blob=bool(data.get('include_blob', False)), + { + 'server_name': server_name, + 'uri': uri, + 'max_bytes': data.get('max_bytes'), + 'include_blob': bool(data.get('include_blob', False)), + }, ) return self.success(data=envelope) diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py index 357618e2d..95be94ecf 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -5,7 +5,6 @@ import quart from ....authz import Permission from ....context import RequestContext from ... import group -from ......pipeline.extension_preferences import normalize_extension_preferences @group.group_class('tools', '/api/v1/tools') @@ -15,29 +14,10 @@ class ToolsRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> list[dict] | None: 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( - request_context, - pipeline_uuid, - ) - if pipeline is None: - return None - - extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) - if not extensions_prefs['enable_all_plugins']: - bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']] - if not extensions_prefs['enable_all_mcp_servers']: - bound_mcp_servers = extensions_prefs['mcp_servers'] - - return await self.ap.tool_mgr.get_resolved_tool_catalog( - request_context, - bound_plugins, - bound_mcp_servers, - include_skill_authoring=True, - ) + try: + return await self.call_application('tool.list', request_context, {'pipeline_uuid': pipeline_uuid}) + except FileNotFoundError: + return None async def initialize(self) -> None: @self.route( diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index d7f801fee..cb7c443ce 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -22,7 +22,7 @@ class SkillsRouterGroup(group.RouterGroup): ) async def list_skills(request_context: RequestContext) -> quart.Response: try: - skills = await self.ap.skill_service.list_skills(request_context) + skills = await self.call_application('skill.list', request_context, {}) except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data={'skills': skills}) @@ -39,7 +39,7 @@ class SkillsRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'Missing required field: name') try: - skill = await self.ap.skill_service.create_skill(request_context, data) + skill = await self.call_application('skill.create', request_context, {'data': data}) return self.success(data={'skill': skill}) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -52,7 +52,7 @@ class SkillsRouterGroup(group.RouterGroup): ) async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response: try: - skill = await self.ap.skill_service.get_skill(request_context, skill_name) + skill = await self.call_application('skill.get', request_context, {'skill_name': skill_name}) except ValueError as exc: return self.http_status(400, -1, str(exc)) if not skill: @@ -72,11 +72,13 @@ class SkillsRouterGroup(group.RouterGroup): if quart.request.method == 'PUT': data = await quart.request.json try: - previous = await self.ap.skill_service.get_skill(request_context, skill_name) + previous = await self.call_application('skill.get', request_context, {'skill_name': skill_name}) except ValueError: previous = None try: - skill = await self.ap.skill_service.update_skill(request_context, skill_name, data) + skill = await self.call_application( + 'skill.update', request_context, {'skill_name': skill_name, 'data': data} + ) except SkillRevisionConflictError as exc: return self.http_status(409, -1, str(exc)) except ValueError as exc: @@ -94,7 +96,7 @@ class SkillsRouterGroup(group.RouterGroup): return self.success(data={'skill': skill}) try: - await self.ap.skill_service.delete_skill(request_context, skill_name) + await self.call_application('skill.delete', request_context, {'skill_name': skill_name}) return self.success() except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -111,11 +113,10 @@ class SkillsRouterGroup(group.RouterGroup): include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true' try: - result = await self.ap.skill_service.list_skill_files( + result = await self.call_application( + 'skill.file.list', request_context, - skill_name, - path=path, - include_hidden=include_hidden, + {'skill_name': skill_name, 'path': path, 'include_hidden': include_hidden}, ) return self.success(data=result) except SkillRevisionConflictError as exc: @@ -131,7 +132,9 @@ class SkillsRouterGroup(group.RouterGroup): ) 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) + result = await self.call_application( + 'skill.file.read', request_context, {'skill_name': skill_name, 'path': path} + ) return self.success(data=result) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -150,12 +153,10 @@ class SkillsRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'Missing required field: content') try: - result = await self.ap.skill_service.write_skill_file( + result = await self.call_application( + 'skill.file.write', request_context, - skill_name, - path, - content, - base_revision=base_revision, + {'skill_name': skill_name, 'path': path, 'content': content, 'base_revision': base_revision}, ) return self.success(data=result) except ValueError as exc: @@ -168,7 +169,7 @@ class SkillsRouterGroup(group.RouterGroup): 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) + skill = await self.call_application('skill.get', request_context, {'skill_name': skill_name}) if not skill: return self.http_status(404, -1, 'Skill not found') return self.success(data={'instructions': skill.get('instructions', '')}) @@ -193,7 +194,7 @@ class SkillsRouterGroup(group.RouterGroup): quart.g.operation_log_resource_id = f'{data.get("owner", "")}/{data.get("repo", "")}' try: - skill = await self.ap.skill_service.install_from_github(request_context, data) + skill = await self.call_application('skill.github.install', request_context, {'data': data}) return self.success(data={'skills': skill}) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -217,7 +218,7 @@ 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(request_context, data) + preview = await self.call_application('skill.github.preview', request_context, {'data': data}) return self.success(data={'skills': preview}) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -240,11 +241,14 @@ class SkillsRouterGroup(group.RouterGroup): quart.g.operation_log_resource_id = file.filename or 'skill' try: - skill = await self.ap.skill_service.install_from_zip_upload( + skill = await self.call_application( + 'skill.zip.install', request_context, - file_bytes=file.read(), - filename=file.filename or '', - source_paths=form.getlist('source_paths'), + { + 'file_bytes': file.read(), + 'filename': file.filename or '', + 'source_paths': form.getlist('source_paths'), + }, ) return self.success(data={'skills': skill}) except ValueError as exc: @@ -264,10 +268,8 @@ class SkillsRouterGroup(group.RouterGroup): 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 '', + preview = await self.call_application( + 'skill.zip.preview', request_context, {'file_bytes': file.read(), 'filename': file.filename or ''} ) return self.success(data={'skills': preview}) except ValueError as exc: diff --git a/src/langbot/pkg/api/http/controller/groups/stats.py b/src/langbot/pkg/api/http/controller/groups/stats.py index e4c8dbdae..76d6734fd 100644 --- a/src/langbot/pkg/api/http/controller/groups/stats.py +++ b/src/langbot/pkg/api/http/controller/groups/stats.py @@ -1,29 +1,6 @@ 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), - } +from ...context import RequestContext @group.group_class('stats', '/api/v1/stats') @@ -36,4 +13,4 @@ class StatsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - return self.success(data=collect_basic_stats(self.ap, request_context)) + return self.success(data=await self.call_application('stats.basic', request_context, {})) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 9947598b6..5e4d8dab0 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -1,14 +1,10 @@ -import json - import quart -import sqlalchemy from .. import group from .....utils import constants from .....cloud.banner import resolve_cloud_banner -from .....entity.persistence.metadata import WorkspaceMetadata from ...authz import Permission -from ...context import PrincipalType, RequestContext +from ...context import RequestContext from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled from .....workspace.invitation_delivery import InvitationDeliveryService @@ -148,41 +144,9 @@ class SystemRouterGroup(group.RouterGroup): 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: - tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) - - async def load_workspace_metadata(): - return await self.ap.persistence_mgr.execute_async( - sqlalchemy.select( - WorkspaceMetadata.key, - WorkspaceMetadata.value, - ).where( - WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid, - WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']), - ) - ) - - cloud_runtime = ( - getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime' - ) - if cloud_runtime: - if not callable(tenant_uow): - raise RuntimeError('Cloud system metadata requires an explicit tenant UoW') - async with tenant_uow(request_context.workspace_uuid): - result = await load_workspace_metadata() - else: - result = await load_workspace_metadata() - # ``execute_async`` deliberately preserves its historical - # AsyncConnection result shape. Selecting the two fields - # explicitly keeps this reader independent of ORM Session - # scalar semantics inside a tenant UoW. - 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 + wizard = await self.call_application('system.wizard.get', request_context, {}) + wizard_status = wizard['status'] or 'none' + wizard_progress = wizard['progress'] except Exception: pass @@ -257,50 +221,11 @@ class SystemRouterGroup(group.RouterGroup): 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" } - """ data = await quart.request.get_json(silent=True) or {} status = data.get('status', 'completed') if status not in ('skipped', 'completed'): return self.http_status(400, 400, f'Invalid wizard status: {status}') - - try: - result = await self.ap.persistence_mgr.execute_async( - 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(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(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(WorkspaceMetadata).where( - WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid, - WorkspaceMetadata.key == 'wizard_progress', - ) - ) - except Exception: - raise - + await self.call_application('system.wizard.complete', request_context, {'status': status}) return self.success(data={}) @self.route( @@ -310,42 +235,8 @@ class SystemRouterGroup(group.RouterGroup): permission=Permission.WORKSPACE_UPDATE, ) async def _(request_context: RequestContext) -> str: - """Save wizard progress to metadata table. - - Accepts JSON body with wizard state fields: - { "step": int, "selected_scenario": str|null, "selected_adapter": str|null, - "created_bot_uuid": str|null, "bot_saved": bool, "selected_runner": str|null } - """ data = await quart.request.get_json(silent=True) or {} - progress_json = json.dumps(data, ensure_ascii=False) - - try: - result = await self.ap.persistence_mgr.execute_async( - 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(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(WorkspaceMetadata).values( - workspace_uuid=request_context.workspace_uuid, - key='wizard_progress', - value=progress_json, - ) - ) - except Exception: - raise - + await self.call_application('system.wizard.progress.update', request_context, data) return self.success(data={}) @self.route( @@ -357,7 +248,7 @@ class SystemRouterGroup(group.RouterGroup): async def _(request_context: RequestContext) -> str: """Resolve Space's best available chat model to this Workspace.""" try: - model = await self.ap.space_service.get_recommended_chat_model(request_context) + model = await self.call_application('system.model.recommended', request_context, {}) except ValueError as exc: return self.http_status(503, -1, str(exc)) return self.success(data=model) @@ -371,11 +262,11 @@ class SystemRouterGroup(group.RouterGroup): async def _(request_context: RequestContext) -> str: """Expose Space's latest persisted model probes to the WebUI.""" try: - models = await self.ap.space_service.get_model_selection() + models = await self.call_application('system.model.availability', request_context, {}) except Exception as exc: self.ap.logger.warning(f'Failed to load LangBot Models availability: {exc}') return self.http_status(503, -1, 'Model availability is unavailable') - return self.success(data={'models': [model.model_dump(mode='json') for model in models]}) + return self.success(data={'models': models}) @self.route( '/tasks', @@ -384,22 +275,14 @@ class SystemRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - task_type = quart.request.args.get('type') - task_kind = quart.request.args.get('kind') - - if task_type == '': - task_type = None - if task_kind == '': - task_kind = None - 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, - public=request_context.principal.principal_type == PrincipalType.API_KEY, + data=await self.call_application( + 'task.list', + request_context, + { + 'task_type': quart.request.args.get('type') or None, + 'task_kind': quart.request.args.get('kind') or None, + }, ) ) @@ -412,21 +295,12 @@ class SystemRouterGroup(group.RouterGroup): async def _(task_id: str, request_context: RequestContext) -> str: try: task_index = int(task_id) - except (TypeError, ValueError): + if task_index < 0: + return self.http_status(404, 404, 'Task not found') + task = await self.call_application('task.get', request_context, {'task_id': task_index}) + except (TypeError, ValueError, FileNotFoundError): return self.http_status(404, 404, 'Task not found') - task = self.ap.task_mgr.get_task_by_id( - task_index, - 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') - - if request_context.principal.principal_type == PrincipalType.API_KEY: - return self.success(data=task.to_public_dict()) - return self.success(data=task.to_dict()) + return self.success(data=task) @self.route( '/storage-analysis', @@ -435,7 +309,7 @@ class SystemRouterGroup(group.RouterGroup): permission=Permission.AUDIT_VIEW, ) async def _(request_context: RequestContext) -> str: - return self.success(data=await self.ap.maintenance_service.get_storage_analysis(request_context)) + return self.success(data=await self.call_application('system.storage.analysis', request_context, {})) @self.route( '/debug/plugin/action', @@ -473,20 +347,4 @@ class SystemRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) async def _(request_context: RequestContext) -> str: - await self.ap.plugin_connector.require_workspace_context(request_context) - plugin_connector_error = 'ok' - is_connected = True - - try: - await self.ap.plugin_connector.ping_plugin_runtime() - except Exception as e: - plugin_connector_error = str(e) - is_connected = False - - return self.success( - data={ - 'is_enable': self.ap.plugin_connector.is_enable_plugin, - 'is_connected': is_connected, - 'plugin_connector_error': plugin_connector_error, - } - ) + return self.success(data=await self.call_application('system.plugin.status', request_context, {})) diff --git a/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py b/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py index 727a3540d..0c608e194 100644 --- a/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py +++ b/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py @@ -12,9 +12,10 @@ class WebhookManagementRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def _(request_context: RequestContext) -> str: - webhooks = await self.ap.webhook_service.get_webhooks( + webhooks = await self.call_application( + 'webhook.list', request_context, - include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE), + {'include_secret': has_permission(request_context, Permission.RESOURCE_MANAGE)}, ) return self.success(data={'webhooks': webhooks}) @@ -32,12 +33,10 @@ class WebhookManagementRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'URL is required') try: - webhook = await self.ap.webhook_service.create_webhook( + webhook = await self.call_application( + 'webhook.create', request_context, - name, - url, - description, - enabled, + {'name': name, 'url': url, 'description': description, 'enabled': enabled}, ) except ValueError as exc: return self.http_status(400, -1, str(exc)) @@ -45,10 +44,13 @@ class WebhookManagementRouterGroup(group.RouterGroup): @self.route('/', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def _(webhook_id: int, request_context: RequestContext) -> str: - webhook = await self.ap.webhook_service.get_webhook( + webhook = await self.call_application( + 'webhook.get', request_context, - webhook_id, - include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE), + { + 'webhook_id': webhook_id, + 'include_secret': has_permission(request_context, Permission.RESOURCE_MANAGE), + }, ) if webhook is None: return self.http_status(404, -1, 'Webhook not found') @@ -62,19 +64,22 @@ class WebhookManagementRouterGroup(group.RouterGroup): 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( + updated = await self.call_application( + 'webhook.update', request_context, - webhook_id, - json_data.get('name'), - json_data.get('url'), - json_data.get('description'), - json_data.get('enabled'), + { + 'webhook_id': webhook_id, + 'name': json_data.get('name'), + 'url': json_data.get('url'), + 'description': json_data.get('description'), + 'enabled': json_data.get('enabled'), + }, ) if not updated: return self.http_status(404, -1, 'Webhook not found') return self.success() - deleted = await self.ap.webhook_service.delete_webhook(request_context, webhook_id) + deleted = await self.call_application('webhook.delete', request_context, {'webhook_id': webhook_id}) if not deleted: return self.http_status(404, -1, 'Webhook not found') return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 4f653a2e3..232ac35ed 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -5,17 +5,14 @@ import typing import quart from ...authz import Permission, permissions_for_role -from ...context import PrincipalType, RequestContext +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 .....workspace.invitation_delivery import InvitationDeliveryService from .....operation_trace import service as settings_service from .. import group from .....i18n import SUPPORTED_LANGUAGES -from .....i18n.workspace import get_workspace_language, set_workspace_language def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]: @@ -65,16 +62,6 @@ def _invitation_payload(invitation: WorkspaceInvitation) -> dict[str, typing.Any @group.group_class('workspaces', '/api/v1/workspaces') class WorkspacesRouterGroup(group.RouterGroup): - async def _run_in_workspace_uow( - self, workspace_uuid: str, operation: typing.Callable[[], typing.Awaitable[typing.Any]] - ): - """Bind collaboration persistence to the selected tenant in Cloud.""" - cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime' - if cloud_runtime: - async with self.ap.persistence_mgr.tenant_uow(workspace_uuid): - return await operation() - return await operation() - async def initialize(self) -> None: @self.route( '/current/language', @@ -83,12 +70,7 @@ class WorkspacesRouterGroup(group.RouterGroup): permission=Permission.WORKSPACE_VIEW, ) async def get_language(request_context: RequestContext): - return self.success( - data={ - 'language': await get_workspace_language(self.ap, request_context.workspace_uuid, refresh=True), - 'supported_languages': list(SUPPORTED_LANGUAGES), - } - ) + return self.success(data=await self.call_application('workspace.language.get', request_context, {})) @self.route( '/current/language', @@ -101,8 +83,9 @@ class WorkspacesRouterGroup(group.RouterGroup): locale = body.get('language') if isinstance(body, dict) else None if locale not in SUPPORTED_LANGUAGES: return self.http_status(400, 'invalid_language', 'Unsupported Workspace language') - locale = await set_workspace_language(self.ap, request_context.workspace_uuid, locale) - return self.success(data={'language': locale}) + return self.success( + data=await self.call_application('workspace.language.update', request_context, {'language': locale}) + ) @self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN) async def _(account) -> typing.Any: @@ -156,88 +139,19 @@ class WorkspacesRouterGroup(group.RouterGroup): @self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW) async def _(request_context: RequestContext) -> typing.Any: - membership = quart.g.workspace_membership - workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) - plan_name: str | None = None - limitation = None - current_plan = None - resolver = getattr(self.ap, 'entitlement_resolver', None) - if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None: - entitlement = await resolver.resolve( - workspace.uuid, - minimum_revision=request_context.entitlement_revision, - ) - plan_name = entitlement.plan_name - if entitlement.plan_revision is not None and entitlement.monthly_bonus_credits is not None: - current_plan = { - 'name': entitlement.plan_name, - 'subscription_started_at': entitlement.subscription_started_at, - 'revision': entitlement.plan_revision, - 'monthly_bonus_credits': entitlement.monthly_bonus_credits, - 'limits': entitlement.limits, - 'entitlement_features': entitlement.features, - } - from ...service.workspace_limits import LIMIT_KEYS - - limitation = {key: entitlement.limit(value) for key, value in LIMIT_KEYS.items()} - if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: - return self.success( - data={ - 'workspace': _workspace_payload(workspace), - 'membership': { - 'uuid': None, - 'workspace_uuid': request_context.workspace_uuid, - 'account_uuid': None, - 'display_name': None, - 'email': None, - 'role': 'owner', - 'status': 'active', - 'joined_at': None, - 'created_at': None, - }, - 'permissions': sorted(request_context.workspace.permissions), - 'placement_generation': request_context.placement_generation, - 'plan_name': plan_name, - 'current_plan': current_plan, - 'limitation': limitation, - } - ) - account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) - if account is None: - return self.http_status(401, 'invalid_authentication', 'Account not found') - return self.success( - data={ - 'workspace': _workspace_payload(workspace), - 'membership': _membership_payload( - membership, - display_name=account.user, - email=account.normalized_email, - ), - 'permissions': sorted(request_context.workspace.permissions), - 'placement_generation': request_context.placement_generation, - 'plan_name': plan_name, - 'current_plan': current_plan, - 'limitation': limitation, - } - ) + return self.success(data=await self.call_application('workspace.current', request_context, {})) @self.route('/', 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)}) + workspace = await self.call_application('workspace.get', request_context, {}) + return self.success(data={'workspace': workspace}) @self.route('//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) - - async def list_members(): - return await self.ap.workspace_collaboration_service.list_members( - workspace_uuid, quart.g.workspace_membership - ) - - members = await self._run_in_workspace_uow(workspace_uuid, list_members) - return self.success(data={'members': [self._member_view_payload(item) for item in members]}) + members = await self.call_application('workspace.member.list', request_context, {}) + return self.success(data={'members': members}) @self.route( '//invitations', @@ -247,54 +161,31 @@ class WorkspacesRouterGroup(group.RouterGroup): async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) if quart.request.method == 'GET': - - async def list_invitations(): - return await self.ap.workspace_collaboration_service.list_invitations( - workspace_uuid, quart.g.workspace_membership - ) - - invitations = await self._run_in_workspace_uow(workspace_uuid, list_invitations) - return self.success(data={'invitations': [_invitation_payload(item) for item in invitations]}) - + invitations = await self.call_application('workspace.invitation.list', request_context, {}) + return self.success(data={'invitations': invitations}) data = await quart.request.get_json(silent=True) or {} - - async def create_invitation(): - return await self.ap.workspace_collaboration_service.create_invitation( - workspace_uuid, - quart.g.workspace_membership, - str(data.get('email', '')), - str(data.get('role', 'viewer')), - ) - - created = await self._run_in_workspace_uow(workspace_uuid, create_invitation) + created = await self.call_application( + 'workspace.invitation.create', + request_context, + { + 'email': str(data.get('email', '')), + 'role': str(data.get('role', 'viewer')), + }, + ) + invitation = created['invitation'] invite_rule = settings_service.ACTION_RULES_BY_ACTION['member_invite'] invite_changes = [ { 'field': 'invitation', 'before': None, - 'after': f'{created.invitation.normalized_email}:{created.invitation.role}', + 'after': f'{invitation["normalized_email"]}:{invitation["role"]}', } ] quart.g.operation_log_changes = invite_changes - quart.g.operation_log_resource_id = created.invitation.uuid - quart.g.operation_log_detail = {'role': created.invitation.role} + quart.g.operation_log_resource_id = invitation['uuid'] + quart.g.operation_log_detail = {'role': invitation['role']} quart.g.operation_log_summary = settings_service.build_summary(invite_rule, invite_changes) - delivery_service = self._invitation_delivery_service() - link = delivery_service.build_invitation_link(created.token) - workspace = await self.ap.workspace_service.get_workspace(workspace_uuid) - delivery = await delivery_service.deliver_invitation( - recipient_email=created.invitation.normalized_email, - workspace_name=workspace.name, - invitation_link=link, - ) - return self.success( - data={ - 'invitation': _invitation_payload(created.invitation), - 'token': created.token, - 'link': link, - 'delivery': delivery.to_public_dict(), - } - ) + return self.success(data=created) @self.route( '//invitations/', @@ -307,14 +198,10 @@ class WorkspacesRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) - - async def revoke_invitation(): - return await self.ap.workspace_collaboration_service.revoke_invitation( - workspace_uuid, invitation_uuid, quart.g.workspace_membership - ) - - invitation = await self._run_in_workspace_uow(workspace_uuid, revoke_invitation) - return self.success(data={'invitation': _invitation_payload(invitation)}) + invitation = await self.call_application( + 'workspace.invitation.revoke', request_context, {'invitation_uuid': invitation_uuid} + ) + return self.success(data={'invitation': invitation}) @self.route( '//members/', @@ -327,80 +214,48 @@ class WorkspacesRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) - - # Capture the previous role before mutating so the operation trace - # can answer "what was changed into what" for this member. - before_role = await self._member_role(workspace_uuid, account_uuid) - + before_role = await self._member_role(request_context, account_uuid) 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') - - async def remove_member(): - return await self.ap.workspace_collaboration_service.remove_member( - workspace_uuid, account_uuid, quart.g.workspace_membership - ) - - member = await self._run_in_workspace_uow(workspace_uuid, remove_member) + member = await self.call_application( + 'workspace.member.remove', request_context, {'account_uuid': account_uuid} + ) await self._publish_member_trace( rule_action='member_remove', account_uuid=account_uuid, changes=[ - { - 'field': 'member', - 'before': f'{account_uuid}:{before_role or "unknown"}', - 'after': 'removed', - } + {'field': 'member', 'before': f'{account_uuid}:{before_role or "unknown"}', 'after': 'removed'} ], ) - return self.success(data={'account_uuid': member.account_uuid}) - + return self.success(data=member) data = await quart.request.get_json(silent=True) or {} - - async def update_member_role(): - return await self.ap.workspace_collaboration_service.update_member_role( - workspace_uuid, - account_uuid, - str(data.get('role', '')), - quart.g.workspace_membership, - ) - - member = await self._run_in_workspace_uow(workspace_uuid, update_member_role) - account = await self.ap.user_service.get_user_by_uuid(member.account_uuid) - - role_changes = [ - {'field': 'role', 'before': before_role, 'after': member.role}, - ] + member = await self.call_application( + 'workspace.member.update', + request_context, + { + 'account_uuid': account_uuid, + 'role': str(data.get('role', '')), + }, + ) await self._publish_member_trace( rule_action='member_role_update', account_uuid=account_uuid, - changes=role_changes, - detail={ - 'member_email': account.normalized_email if account is not None else None, - }, - ) - return self.success( - data={ - 'member': _membership_payload( - member, - display_name=account.user if account is not None else '', - email=account.normalized_email if account is not None else '', - ) - } + changes=[{'field': 'role', 'before': before_role, 'after': member['role']}], + detail={'member_email': member['email'] or None}, ) + return self.success(data={'member': member}) - async def _member_role(self, workspace_uuid: str, account_uuid: str) -> str | None: + async def _member_role(self, request_context: RequestContext, account_uuid: str) -> str | None: """Return a member's current role without failing the request.""" try: - members = await self.ap.workspace_collaboration_service.list_members( - workspace_uuid, quart.g.workspace_membership - ) + members = await self.call_application('workspace.member.list', request_context, {}) except Exception: return None for view in members: - if view.membership.account_uuid == account_uuid: - return view.membership.role + if view['account_uuid'] == account_uuid: + return view['role'] return None async def _publish_member_trace( @@ -429,21 +284,6 @@ class WorkspacesRouterGroup(group.RouterGroup): if workspace_uuid != request_context.workspace_uuid: raise WorkspaceNotFoundError('Workspace not found') - def _invitation_delivery_service(self) -> InvitationDeliveryService: - service = getattr(self.ap, 'invitation_delivery_service', None) - if service is None: - service = InvitationDeliveryService(self.ap) - self.ap.invitation_delivery_service = service - return service - - @staticmethod - def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]: - return _membership_payload( - view.membership, - display_name=view.display_name, - email=view.email, - ) - @group.group_class('invitations', '/api/v1/invitations') class InvitationsRouterGroup(group.RouterGroup): diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index fd9599f64..8022e9286 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -7,6 +7,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field from langbot_plugin.api.entities.builtin.resource.tool import LLMTool +from ...application.transport import invoke_application, transport_records_audit from ..authz import Permission, require_permission from ..context import ExecutionContext from .secrets import redact_secrets @@ -252,16 +253,25 @@ def tool_definitions(context) -> list[LLMTool]: async def execute_tool(ap, context, name: str, arguments: dict): + # A background tool has its own audit record even when its parent is an HTTP turn. + token = transport_records_audit.set(False) + try: + return await _execute_tool(ap, context, name, arguments) + finally: + transport_records_audit.reset(token) + + +async def _execute_tool(ap, context, name: str, arguments: dict): args = validate_call(context, name, arguments).model_dump(mode='json') if name == 'list_resources': readers = { - 'models': ap.llm_model_service.get_llm_models, - 'embedding_models': ap.embedding_models_service.get_embedding_models, - 'pipelines': ap.pipeline_service.get_pipelines, - 'knowledge_bases': ap.knowledge_service.get_knowledge_bases, - 'knowledge_engines': ap.knowledge_service.list_knowledge_engines, + 'models': 'model.llm.list', + 'embedding_models': 'model.embedding.list', + 'pipelines': 'pipeline.list', + 'knowledge_bases': 'knowledge.base.list', + 'knowledge_engines': 'knowledge.engine.list', } - resources = await readers[args['kind']](context) + resources = await invoke_application(ap, readers[args['kind']], context, {}) if args['kind'] != 'knowledge_engines': # Lists discover resources; get_pipeline / get_knowledge_schema supply configuration details. fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id') @@ -271,13 +281,17 @@ async def execute_tool(ap, context, name: str, arguments: dict): # Delegates to the operation-trace service, so the assistant shows the # same records and tamper verification as the settings panel, then # compacts them so a full page still fits the per-result budget. - result = await ap.workspace_settings_service.query_logs( - context.workspace_uuid, - limit=args['limit'], - search=args['search'], - action=args['action'], - resource_type=args['resource_type'], - actor_account_uuid=args['actor'], + result = await invoke_application( + ap, + 'settings.operation_logs.list', + context, + { + 'limit': args['limit'], + 'search': args['search'], + 'action': args['action'], + 'resource_type': args['resource_type'], + 'actor_account_uuid': args['actor'], + }, ) compact = _compact_operation_records(result) # Echo the applied filters so the model can see why a result is empty @@ -285,11 +299,15 @@ async def execute_tool(ap, context, name: str, arguments: dict): compact['query'] = {key: args[key] for key in ('search', 'action', 'resource_type', 'actor') if args.get(key)} return compact if name == 'get_pipeline': - return await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid']) + return await invoke_application(ap, 'pipeline.get', context, {'pipeline_uuid': args['pipeline_uuid']}) if name == 'get_knowledge_schema': return { - 'creation': await ap.knowledge_service.get_engine_creation_schema(context, args['plugin_id']), - 'retrieval': await ap.knowledge_service.get_engine_retrieval_schema(context, args['plugin_id']), + 'creation': await invoke_application( + ap, 'knowledge.engine.creation_schema', context, {'plugin_id': args['plugin_id']} + ), + 'retrieval': await invoke_application( + ap, 'knowledge.engine.retrieval_schema', context, {'plugin_id': args['plugin_id']} + ), } if name == 'create_pipeline': args['extensions_preferences'] = { @@ -301,15 +319,17 @@ async def execute_tool(ap, context, name: str, arguments: dict): 'skills': [], 'mcp_resources': [], } - resource_id = await ap.pipeline_service.create_pipeline(context, args) + resource_id = await invoke_application(ap, 'pipeline.create', context, {'pipeline_data': args}) return {'uuid': resource_id, 'url': f'/home/pipelines?id={resource_id}', 'configured': False} if name == 'configure_pipeline': - pipeline = await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid'], include_secret=True) + pipeline = await invoke_application( + ap, 'pipeline.get', context, {'pipeline_uuid': args['pipeline_uuid'], 'include_secret': True} + ) if pipeline is None: raise ValueError('Pipeline not found') await ap.model_mgr.get_model_by_uuid(ExecutionContext.from_request(context), args['model_uuid']) for kb_id in args['knowledge_base_uuids']: - if await ap.knowledge_service.get_knowledge_base(context, kb_id) is None: + if await invoke_application(ap, 'knowledge.base.get', context, {'kb_uuid': kb_id}) is None: raise ValueError('Knowledge base not found') config = copy.deepcopy(pipeline['config']) runner_id = 'plugin:langbot-team/LocalAgent/default' @@ -340,7 +360,9 @@ async def execute_tool(ap, context, name: str, arguments: dict): for p in plugins ): update['extensions_preferences'] = {**prefs, 'plugins': [*plugins, entry]} - await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], update) + await invoke_application( + ap, 'pipeline.update', context, {'pipeline_uuid': args['pipeline_uuid'], 'pipeline_data': update} + ) return {'uuid': args['pipeline_uuid'], 'url': f'/home/pipelines?id={args["pipeline_uuid"]}'} - resource_id = await ap.knowledge_service.create_knowledge_base(context, args) + resource_id = await invoke_application(ap, 'knowledge.base.create', context, {'kb_data': args}) return {'uuid': resource_id, 'url': f'/home/knowledge?id={resource_id}'} diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 506fb4de1..d254e8cb0 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -1156,7 +1156,7 @@ class BotService: bot_uuid: str, target_type: str, target_id: str, - message_chain_data: dict, + message_chain_data: list[dict], ) -> None: """Send message to a specific target via bot @@ -1164,7 +1164,7 @@ class BotService: bot_uuid: The UUID of the bot target_type: The type of the target, can be "group", "person" target_id: The ID of the target - message_chain_data: The message chain data in dict format + message_chain_data: The message chain components """ if await self.get_bot(context, bot_uuid, include_secret=False) is None: raise WorkspaceNotFoundError('Bot not found') diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 934cf97f9..dfa357c2a 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -352,7 +352,12 @@ class PipelineService: if self.ap.plugin_connector.is_enable_plugin: await self.ap.plugin_connector.require_workspace_context(context) plugins = await self.ap.plugin_connector.list_plugins(component_kinds=['Command', 'EventListener', 'Tool']) - prefs = pipeline.get('extensions_preferences', {}) + prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) + try: + available_skills = await self.ap.skill_service.list_skills(context) + except Exception as exc: + self.ap.logger.warning('Unable to list skills for pipeline extensions: %s', exc) + available_skills = [] return { 'enable_all_plugins': prefs.get('enable_all_plugins', True), 'enable_all_mcp_servers': prefs.get('enable_all_mcp_servers', True), @@ -364,7 +369,7 @@ class PipelineService: 'bound_mcp_resources': prefs.get('mcp_resources', []), 'mcp_resource_agent_read_enabled': prefs.get('mcp_resource_agent_read_enabled', True), 'bound_skills': prefs.get('skills', []), - 'available_skills': await self.ap.skill_service.list_skills(context), + 'available_skills': available_skills, } async def update_pipeline_extensions( diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 967a964ca..9fa354469 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -1,8 +1,7 @@ """LangBot MCP server definition. -Wraps a curated subset of LangBot's HTTP service API as MCP tools. Tools call -the existing service layer directly (not the HTTP API over the network), so the -MCP surface stays aligned with the API by construction. +Wraps a curated subset of LangBot's Application API as MCP tools. Tools use +the same authorized application operations as HTTP consumers. IMPORTANT: when you add, remove, or change an HTTP API endpoint that should be agent-accessible, update the corresponding MCP tool here AND the skills under @@ -23,10 +22,11 @@ import functools from mcp.server.fastmcp import FastMCP +from ..application.transport import invoke_application from ..http.authz import Permission, require_permission from .context import get_request_context from ...i18n import language_scope -from ...i18n.workspace import get_workspace_language, set_workspace_language +from ...i18n.workspace import get_workspace_language if typing.TYPE_CHECKING: from ...core import app as app_module @@ -84,7 +84,8 @@ class LangBotMCPServer: @mcp.tool(description='Get the Workspace language used for backend messages and default bot replies.') async def get_workspace_message_language() -> str: context = _authorized(Permission.WORKSPACE_VIEW) - return _dump({'language': await get_workspace_language(ap, context.workspace_uuid, refresh=True)}) + result = await invoke_application(ap, 'workspace.language.get', context, {}) + return _dump({'language': result['language']}) @mcp.tool( description='Set the Workspace message language. Requires workspace.update; does not change personal UI language.' @@ -93,7 +94,7 @@ class LangBotMCPServer: language: typing.Literal['en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN'], ) -> str: context = _authorized(Permission.WORKSPACE_UPDATE) - return _dump({'language': await set_workspace_language(ap, context.workspace_uuid, language)}) + return _dump(await invoke_application(ap, 'workspace.language.update', context, {'language': language})) # ----- System (read-only) -------------------------------------- # @mcp.tool(description='Get basic LangBot system/runtime information (version, edition).') @@ -113,23 +114,38 @@ class LangBotMCPServer: @mcp.tool(description='List knowledge engines and their capabilities.') async def list_knowledge_engines() -> str: - return _dump(await ap.knowledge_service.list_knowledge_engines(_authorized(Permission.RESOURCE_VIEW))) + return _dump( + await invoke_application(ap, 'knowledge.engine.list', _authorized(Permission.RESOURCE_VIEW), {}) + ) @mcp.tool(description='Get creation or retrieval schema for an author/name knowledge engine.') async def get_knowledge_engine_schema(plugin_id: str, kind: typing.Literal['creation', 'retrieval']) -> str: context = _authorized(Permission.RESOURCE_VIEW) if kind == 'creation': - return _dump(await ap.knowledge_service.get_engine_creation_schema(context, plugin_id)) - return _dump(await ap.knowledge_service.get_engine_retrieval_schema(context, plugin_id)) + return _dump( + await invoke_application(ap, 'knowledge.engine.creation_schema', context, {'plugin_id': plugin_id}) + ) + return _dump( + await invoke_application(ap, 'knowledge.engine.retrieval_schema', context, {'plugin_id': plugin_id}) + ) @mcp.tool(description='List document parsers, optionally filtered by MIME type.') async def list_knowledge_parsers(mime_type: str | None = None) -> str: - return _dump(await ap.knowledge_service.list_parsers(_authorized(Permission.RESOURCE_VIEW), mime_type)) + return _dump( + await invoke_application( + ap, 'knowledge.parser.list', _authorized(Permission.RESOURCE_VIEW), {'mime_type': mime_type} + ) + ) @mcp.tool(description='Read Pipeline extension bindings, enable-all switches and available extensions.') async def get_pipeline_extensions(pipeline_uuid: str) -> str: return _dump( - await ap.pipeline_service.get_pipeline_extensions(_authorized(Permission.RESOURCE_VIEW), pipeline_uuid) + await invoke_application( + ap, + 'pipeline.extensions.get', + _authorized(Permission.RESOURCE_VIEW), + {'pipeline_uuid': pipeline_uuid}, + ) ) @mcp.tool( @@ -152,27 +168,38 @@ class LangBotMCPServer: ) -> str: context = _authorized(Permission.RESOURCE_MANAGE) require_permission(context, Permission.RESOURCE_VIEW) - await ap.pipeline_service.update_pipeline_extensions( + await invoke_application( + ap, + 'pipeline.extensions.update', context, - pipeline_uuid, - bound_plugins, - bound_mcp_servers, - enable_all_plugins, - enable_all_mcp_servers, - bound_skills, - enable_all_skills, - bound_mcp_resources, - mcp_resource_agent_read_enabled, + { + 'pipeline_uuid': pipeline_uuid, + 'bound_plugins': bound_plugins, + 'bound_mcp_servers': bound_mcp_servers, + 'enable_all_plugins': enable_all_plugins, + 'enable_all_mcp_servers': 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 _dump( + await invoke_application(ap, 'pipeline.extensions.get', context, {'pipeline_uuid': pipeline_uuid}) ) - return _dump(await ap.pipeline_service.get_pipeline_extensions(context, pipeline_uuid)) @mcp.tool( description='Run one Pipeline turn in a fresh session. Calls configured models/tools. Never retry an unknown outcome automatically.' ) async def run_pipeline(pipeline_uuid: str, message: str) -> str: - from ..http.service.pipeline_run import run_pipeline as execute - - return _dump(await execute(ap, _authorized(Permission.RUNTIME_OPERATE), pipeline_uuid, message)) + return _dump( + await invoke_application( + ap, + 'pipeline.run', + _authorized(Permission.RUNTIME_OPERATE), + {'pipeline_uuid': pipeline_uuid, 'text': message}, + ) + ) @mcp.tool( description='Read Workspace sandbox status, sessions or recent errors. Does not create execution sessions.' @@ -180,12 +207,12 @@ class LangBotMCPServer: async def get_sandbox_diagnostics(kind: typing.Literal['status', 'sessions', 'errors'] = 'status') -> str: context = _authorized(Permission.RESOURCE_VIEW if kind == 'status' else Permission.AUDIT_VIEW) if kind == 'status': - return _dump(await ap.box_service.get_status(context)) + result = await invoke_application(ap, 'box.status', context, {}) + result.pop('hidden', None) + return _dump(result) if kind == 'sessions': - return _dump(await ap.box_service.get_sessions(context)) - if ap.box_service.managed_admission_required: - await ap.box_service.require_workspace_sandbox(context) - return _dump(ap.box_service.get_recent_errors(context)) + return _dump(await invoke_application(ap, 'box.sessions.list', context, {})) + return _dump(await invoke_application(ap, 'box.errors.list', context, {})) @mcp.tool( description='Read bounded Workspace runtime records. Message image URLs may be renewed through the connected OneBot peer without downloading images or rewriting stored records; URLs remain temporary. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind. Sessions read stored conversation summaries and do not support mode or execution_statuses.' @@ -219,8 +246,8 @@ class LangBotMCPServer: value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None) kwargs[field] = value limit, offset = ap.monitoring_service.normalize_page_window(limit, offset) - rows, total = await getattr(ap.monitoring_service, 'get_' + kind)( - context, limit=limit, offset=offset, **kwargs + rows, total = await invoke_application( + ap, 'monitoring.' + kind, context, {'limit': limit, 'offset': offset, **kwargs} ) return _dump({kind: rows, 'total': total, 'limit': limit, 'offset': offset}) @@ -228,24 +255,29 @@ class LangBotMCPServer: async def get_monitoring_details(kind: typing.Literal['message', 'session'], identifier: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) if kind == 'message': - return _dump(await ap.monitoring_service.get_message_details(context, identifier)) - return _dump(await ap.monitoring_service.get_session_analysis(context, identifier)) + return _dump( + await invoke_application(ap, 'monitoring.message.detail', context, {'message_id': identifier}) + ) + return _dump( + await invoke_application(ap, 'monitoring.session.analysis', context, {'session_id': identifier}) + ) @mcp.tool( description='Read a bounded snapshot of active and recently started Workspace executions, including the latest progress event. No percentage is estimated.' ) async def get_inflight_executions() -> str: - return _dump(await ap.monitoring_service.get_inflight_snapshot(_authorized(Permission.RESOURCE_VIEW))) + return _dump(await invoke_application(ap, 'monitoring.inflight', _authorized(Permission.RESOURCE_VIEW), {})) @mcp.tool( description='Reset a bot session model context, preserving monitoring history. Refuses busy sessions.' ) async def reset_session_context(bot_id: str, session_id: str) -> str: return _dump( - await ap.monitoring_service.reset_session_context( + await invoke_application( + ap, + 'monitoring.session.reset', _authorized(Permission.RESOURCE_MANAGE), - bot_id, - session_id, + {'bot_id': bot_id, 'session_id': session_id}, ) ) @@ -261,18 +293,20 @@ class LangBotMCPServer: socket_mode: bool = False, redirect_url: str = '', ) -> str: - from langbot.pkg.api.http.service.slack_setup import get_slack_setup - return _dump( - await get_slack_setup(ap).start( + await invoke_application( + ap, + 'platform.setup.slack.start', _authorized(Permission.RESOURCE_MANAGE), - bot_uuid=bot_uuid, - access_token=access_token, - refresh_token=refresh_token, - webhook_url=webhook_url, - name=name, - socket_mode=socket_mode, - redirect_url=redirect_url, + { + 'bot_uuid': bot_uuid, + 'access_token': access_token, + 'refresh_token': refresh_token, + 'webhook_url': webhook_url, + 'name': name, + 'socket_mode': socket_mode, + 'redirect_url': redirect_url, + }, ) ) @@ -280,19 +314,22 @@ class LangBotMCPServer: description='Read an owned Slack setup session. Success includes adapter credentials; save them with the existing bot update tool. Socket Mode additionally needs an App-Level Token with connections:write.' ) async def get_slack_setup_status(session_id: str) -> str: - from langbot.pkg.api.http.service.slack_setup import get_slack_setup - - return _dump(get_slack_setup(ap).status(_authorized(Permission.RESOURCE_MANAGE), session_id)) + return _dump( + await invoke_application( + ap, + 'platform.setup.slack.status', + _authorized(Permission.RESOURCE_MANAGE), + {'session_id': session_id}, + ) + ) @mcp.tool( description='Discard an owned Slack setup session and its temporary credentials. Does not delete the Slack app.' ) async def cancel_slack_setup(session_id: str) -> str: - from langbot.pkg.api.http.service.slack_setup import get_slack_setup - - service = get_slack_setup(ap) - service.owned(_authorized(Permission.RESOURCE_MANAGE), session_id) - service.discard(session_id) + await invoke_application( + ap, 'platform.setup.slack.cancel', _authorized(Permission.RESOURCE_MANAGE), {'session_id': session_id} + ) return _dump({'cancelled': True}) @mcp.tool( @@ -308,14 +345,18 @@ class LangBotMCPServer: ) -> str: context = _authorized(Permission.RESOURCE_VIEW) return _dump( - await ap.monitoring_service.get_executions( + await invoke_application( + ap, + 'monitoring.executions', context, - limit=limit, - offset=offset, - bot_ids=bot_ids, - pipeline_ids=pipeline_ids, - statuses=statuses, - mode=mode, + { + 'limit': limit, + 'offset': offset, + 'bot_ids': bot_ids, + 'pipeline_ids': pipeline_ids, + 'statuses': statuses, + 'mode': mode, + }, ) ) @@ -331,13 +372,17 @@ class LangBotMCPServer: ) -> str: context = _authorized(Permission.RESOURCE_VIEW) return _dump( - await ap.monitoring_service.get_execution_detail( + await invoke_application( + ap, + 'monitoring.execution.detail', context, - source, - identifier, - section=section, - offset=offset, - limit=limit, + { + 'source': source, + 'execution_id': identifier, + 'section': section, + 'offset': offset, + 'limit': limit, + }, ) ) @@ -345,12 +390,14 @@ class LangBotMCPServer: @mcp.tool(description='List all messaging-platform bots. Secrets are redacted.') async def list_bots() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.bot_service.get_bots(context, include_secret=False)) + return _dump(await invoke_application(ap, 'bot.list', 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: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.bot_service.get_bot(context, bot_uuid, include_secret=False)) + return _dump( + await invoke_application(ap, 'bot.get', context, {'bot_uuid': bot_uuid, 'include_secret': False}) + ) @mcp.tool( description=( @@ -363,7 +410,7 @@ class LangBotMCPServer: ) async def create_bot(bot_data: dict) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)}) + return _dump({'uuid': await invoke_application(ap, 'bot.create', context, {'bot_data': bot_data})}) @mcp.tool( description=( @@ -374,18 +421,22 @@ class LangBotMCPServer: ) async def update_bot(bot_uuid: str, bot_data: dict) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - await ap.bot_service.update_bot(context, bot_uuid, bot_data) + await invoke_application(ap, 'bot.update', context, {'bot_uuid': bot_uuid, 'bot_data': bot_data}) return _dump({'ok': True}) @mcp.tool(description='Delete a bot by UUID.') async def delete_bot(bot_uuid: str) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - await ap.bot_service.delete_bot(context, bot_uuid) + await invoke_application(ap, 'bot.delete', context, {'bot_uuid': bot_uuid}) return _dump({'ok': True}) @mcp.tool(description='List recent runtime route status for a bot event route table.') async def list_bot_event_route_statuses(bot_uuid: str) -> str: - return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid)) + return _dump( + await invoke_application( + ap, 'bot.routes.status', _authorized(Permission.RESOURCE_VIEW), {'bot_uuid': bot_uuid} + ) + ) @mcp.tool( description=( @@ -396,7 +447,7 @@ class LangBotMCPServer: ) async def preview_bot_group_filter_migration(bot_uuid: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.bot_service.preview_group_filter_migration(context, bot_uuid)) + return _dump(await invoke_application(ap, 'bot.group_filter.preview', context, {'bot_uuid': bot_uuid})) @mcp.tool( description=( @@ -409,10 +460,11 @@ class LangBotMCPServer: async def apply_bot_group_filter_migration(bot_uuid: str, disable_pipeline_at_bot: bool = True) -> str: context = _authorized(Permission.RESOURCE_MANAGE) return _dump( - await ap.bot_service.apply_group_filter_migration( + await invoke_application( + ap, + 'bot.group_filter.apply', context, - bot_uuid, - disable_pipeline_at_bot=disable_pipeline_at_bot, + {'bot_uuid': bot_uuid, 'disable_pipeline_at_bot': disable_pipeline_at_bot}, ) ) @@ -420,12 +472,12 @@ class LangBotMCPServer: @mcp.tool(description='List all pipelines.') async def list_pipelines() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.pipeline_service.get_pipelines(context)) + return _dump(await invoke_application(ap, 'pipeline.list', context, {})) @mcp.tool(description='Get a single pipeline by UUID.') async def get_pipeline(pipeline_uuid: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.pipeline_service.get_pipeline(context, pipeline_uuid)) + return _dump(await invoke_application(ap, 'pipeline.get', context, {'pipeline_uuid': pipeline_uuid})) @mcp.tool( description=( @@ -439,10 +491,8 @@ class LangBotMCPServer: create_as_default = pipeline_data.get('is_default') is True return _dump( { - 'uuid': await ap.pipeline_service.create_pipeline( - context, - pipeline_data, - default=create_as_default, + 'uuid': await invoke_application( + ap, 'pipeline.create', context, {'pipeline_data': pipeline_data, 'default': create_as_default} ) } ) @@ -450,25 +500,27 @@ class LangBotMCPServer: @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: context = _authorized(Permission.RESOURCE_MANAGE) - await ap.pipeline_service.update_pipeline(context, pipeline_uuid, pipeline_data) + await invoke_application( + ap, 'pipeline.update', context, {'pipeline_uuid': pipeline_uuid, 'pipeline_data': pipeline_data} + ) return _dump({'ok': True}) @mcp.tool(description='Delete a pipeline by UUID.') async def delete_pipeline(pipeline_uuid: str) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - await ap.pipeline_service.delete_pipeline(context, pipeline_uuid) + await invoke_application(ap, 'pipeline.delete', context, {'pipeline_uuid': pipeline_uuid}) return _dump({'ok': True}) # ----- Processors ---------------------------------------------- # @mcp.tool(description='List product-level processors, including Agents, Pipelines and Event processors.') async def list_processors() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.agent_service.get_agents(context)) + return _dump(await invoke_application(ap, 'agent.list', context, {})) @mcp.tool(description='Get an Agent, Pipeline or Event processor by UUID.') async def get_processor(processor_uuid: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.agent_service.get_agent(context, processor_uuid)) + return _dump(await invoke_application(ap, 'agent.get', context, {'agent_uuid': processor_uuid})) @mcp.tool( description=( @@ -480,18 +532,20 @@ class LangBotMCPServer: ) async def create_processor(processor_data: dict) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - return _dump(await ap.agent_service.create_agent(context, processor_data)) + return _dump(await invoke_application(ap, 'agent.create', context, {'agent_data': processor_data})) @mcp.tool(description='Update an Agent, Pipeline or Event processor by UUID.') async def update_processor(processor_uuid: str, processor_data: dict) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - await ap.agent_service.update_agent(context, processor_uuid, processor_data) + await invoke_application( + ap, 'agent.update', context, {'agent_uuid': processor_uuid, 'agent_data': processor_data} + ) return _dump({'ok': True}) @mcp.tool(description='Delete an Agent, Pipeline or Event processor by UUID.') async def delete_processor(processor_uuid: str) -> str: context = _authorized(Permission.RESOURCE_MANAGE) - await ap.agent_service.delete_agent(context, processor_uuid) + await invoke_application(ap, 'agent.delete', context, {'agent_uuid': processor_uuid}) return _dump({'ok': True}) @mcp.tool( @@ -499,7 +553,7 @@ class LangBotMCPServer: ) async def get_processor_metadata() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.agent_service.get_agent_metadata(context)) + return _dump(await invoke_application(ap, 'agent.metadata', context, {})) @mcp.tool( description='List one Agent or plugin processor run history; use before_id to page older runs. ' @@ -507,7 +561,11 @@ class LangBotMCPServer: ) async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id)) + return _dump( + await invoke_application( + ap, 'agent.runs.list', context, {'processor_id': processor_uuid, 'before_id': before_id} + ) + ) @mcp.tool( description='Read logs and action results for an Agent or plugin processor run; page using after_sequence.' @@ -519,11 +577,11 @@ class LangBotMCPServer: ) -> str: context = _authorized(Permission.RESOURCE_VIEW) return _dump( - await ap.agent_service.get_processor_run_events( + await invoke_application( + ap, + 'agent.runs.events', context, - processor_uuid, - run_id, - after_sequence=after_sequence, + {'processor_id': processor_uuid, 'run_id': run_id, 'after_sequence': after_sequence}, ) ) @@ -540,19 +598,21 @@ class LangBotMCPServer: ) async def debug_agent(processor_uuid: str, payload: dict) -> str: context = _authorized(Permission.RUNTIME_OPERATE) - return _dump(await ap.agent_service.debug_agent(context, processor_uuid, payload)) + return _dump( + await invoke_application(ap, 'agent.debug', context, {'agent_uuid': processor_uuid, 'payload': payload}) + ) @mcp.tool(description='Get the single starred LLM model UUID for the current Workspace.') async def get_starred_model() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump({'uuid': await ap.llm_model_service.get_starred_model(context)}) + return _dump({'uuid': await invoke_application(ap, 'model.llm.starred.get', context, {})}) @mcp.tool( description='Star one LLM model for this Workspace, replacing the previous choice. Pass null to clear.' ) async def set_starred_model(model_uuid: str | None = None) -> str: context = _authorized(Permission.PROVIDER_SECRET_MANAGE) - await ap.llm_model_service.set_starred_model(context, model_uuid) + await invoke_application(ap, 'model.llm.starred.set', context, {'model_uuid': model_uuid}) return _dump({'uuid': model_uuid}) @mcp.tool( @@ -560,12 +620,12 @@ class LangBotMCPServer: ) async def get_default_model() -> str: context = _authorized(Permission.RESOURCE_MANAGE) - return _dump({'uuid': await ap.llm_model_service.get_default_model(context)}) + return _dump({'uuid': await invoke_application(ap, 'model.llm.default.get', context, {})}) @mcp.tool(description='List all configured LLM models. Secrets are redacted.') async def list_llm_models() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False)) + return _dump(await invoke_application(ap, 'model.llm.list', context, {'include_secret': False})) @mcp.tool( description=( @@ -576,35 +636,41 @@ class LangBotMCPServer: ) async def get_llm_model(model_uuid: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False)) + return _dump( + await invoke_application( + ap, 'model.llm.get', context, {'model_uuid': model_uuid, 'include_secret': False} + ) + ) @mcp.tool(description='List all configured embedding models.') async def list_embedding_models() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.embedding_models_service.get_embedding_models(context, include_secret=False)) + return _dump(await invoke_application(ap, 'model.embedding.list', context, {'include_secret': False})) @mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).') async def list_model_providers() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.provider_service.get_providers(context, include_secret=False)) + return _dump(await invoke_application(ap, 'provider.list', context, {'include_secret': False})) # ----- Knowledge bases ----------------------------------------- # @mcp.tool(description='List all knowledge bases (RAG).') async def list_knowledge_bases() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.knowledge_service.get_knowledge_bases(context)) + return _dump(await invoke_application(ap, 'knowledge.base.list', context, {})) @mcp.tool(description='Get a single knowledge base by UUID.') async def get_knowledge_base(kb_uuid: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.knowledge_service.get_knowledge_base(context, kb_uuid)) + return _dump(await invoke_application(ap, 'knowledge.base.get', context, {'kb_uuid': 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: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.knowledge_service.retrieve_knowledge_base(context, kb_uuid, query)) + return _dump( + await invoke_application(ap, 'knowledge.base.retrieve', context, {'kb_uuid': kb_uuid, 'query': query}) + ) # ----- MCP servers (LangBot as MCP client) --------------------- # @mcp.tool( @@ -614,18 +680,18 @@ class LangBotMCPServer: ) async def list_mcp_servers() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.mcp_service.get_mcp_servers(context)) + return _dump(await invoke_application(ap, 'mcp.server.list', context, {})) # ----- Skills -------------------------------------------------- # @mcp.tool(description='List installed skills.') async def list_skills() -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.skill_service.list_skills(context)) + return _dump(await invoke_application(ap, 'skill.list', context, {})) @mcp.tool(description='Get a single skill by name.') async def get_skill(skill_name: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) - return _dump(await ap.skill_service.get_skill(context, skill_name)) + return _dump(await invoke_application(ap, 'skill.get', context, {'skill_name': skill_name})) def tool(self, **options): """Register a tool boundary before its authorization and service call.""" diff --git a/src/langbot/pkg/operation_trace/routes.py b/src/langbot/pkg/operation_trace/routes.py index e408ae6eb..e079ebad1 100644 --- a/src/langbot/pkg/operation_trace/routes.py +++ b/src/langbot/pkg/operation_trace/routes.py @@ -79,7 +79,7 @@ class SettingsRouterGroup(group.RouterGroup): async def _(request_context: RequestContext) -> typing.Any: service = self.ap.workspace_settings_service if quart.request.method == 'GET': - return self.success(data=await service.describe_governance(request_context.workspace_uuid)) + return self.success(data=await self.call_application('settings.governance.get', request_context, {})) if not settings_service.role_may_configure(request_context.workspace.role): return self.http_status( @@ -92,13 +92,16 @@ class SettingsRouterGroup(group.RouterGroup): # The previous level is captured before the write so the trace can # answer "what was changed into what" for the governance change # itself, not only for the resources it protects. - before = await service.describe_governance(request_context.workspace_uuid) - updated = await service.set_operation_level( - request_context.workspace_uuid, - data.get('level', before['configured_level']), - retention_days=data.get('retention_days'), - max_rows=data.get('max_rows'), - dedupe_window_seconds=data.get('dedupe_window_seconds'), + before = await self.call_application('settings.governance.get', request_context, {}) + updated = await self.call_application( + 'settings.governance.update', + request_context, + { + 'level': data.get('level', before['configured_level']), + 'retention_days': data.get('retention_days'), + 'max_rows': data.get('max_rows'), + 'dedupe_window_seconds': data.get('dedupe_window_seconds'), + }, ) account_name = None @@ -144,6 +147,7 @@ class SettingsRouterGroup(group.RouterGroup): ), changes=changes, ) + quart.g.operation_log_recorded = True return self.success(data=updated) @self.route( @@ -155,23 +159,26 @@ class SettingsRouterGroup(group.RouterGroup): async def _(request_context: RequestContext) -> typing.Any: # Read-only by design: operation records are immutable and cannot # be deleted on demand. Only the retention policy may prune them. - service = self.ap.workspace_settings_service - args = quart.request.args level_raw = args.get('level') - result = await service.query_logs( - request_context.workspace_uuid, - limit=_parse_int(args.get('limit'), settings_service.DEFAULT_PAGE_SIZE), - offset=_parse_int(args.get('offset'), 0), - action=args.get('action') or None, - resource_type=args.get('resource_type') or None, - actor_account_uuid=args.get('actor') or None, - level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None, - since=_parse_timestamp(args.get('since')), - until=_parse_timestamp(args.get('until')), - # Optional verification filter: lets the panel turn the three - # tamper counters into a drill-down instead of a dead badge. - integrity=args.get('integrity') or None, + result = await self.call_application( + 'settings.operation_logs.list', + request_context, + { + 'limit': max( + 1, + _parse_int(args.get('limit'), settings_service.DEFAULT_PAGE_SIZE) + or settings_service.DEFAULT_PAGE_SIZE, + ), + 'offset': max(0, _parse_int(args.get('offset'), 0)), + 'action': args.get('action') or None, + 'resource_type': args.get('resource_type') or None, + 'actor_account_uuid': args.get('actor') or None, + 'level': _parse_int(level_raw, 0) if level_raw not in (None, '') else None, + 'since': _parse_timestamp(args.get('since')), + 'until': _parse_timestamp(args.get('until')), + 'integrity': args.get('integrity') or None, + }, ) return self.success(data=result) @@ -182,9 +189,7 @@ class SettingsRouterGroup(group.RouterGroup): permission=Permission.AUDIT_VIEW, ) async def _(request_context: RequestContext) -> typing.Any: - service = self.ap.workspace_settings_service - options = await service.list_filter_options(request_context.workspace_uuid) - options['governance'] = await service.describe_governance(request_context.workspace_uuid) + options = await self.call_application('settings.operation_logs.filters', request_context, {}) return self.success(data=options) @self.route( @@ -202,15 +207,17 @@ class SettingsRouterGroup(group.RouterGroup): args = quart.request.args level_raw = args.get('level') - service = self.ap.workspace_settings_service - document = await service.export_logs( - request_context.workspace_uuid, - action=args.get('action') or None, - resource_type=args.get('resource_type') or None, - actor_account_uuid=args.get('actor') or None, - level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None, - since=_parse_timestamp(args.get('since')), - until=_parse_timestamp(args.get('until')), + document = await self.call_application( + 'settings.operation_logs.export', + request_context, + { + 'action': args.get('action') or None, + 'resource_type': args.get('resource_type') or None, + 'actor_account_uuid': args.get('actor') or None, + 'level': _parse_int(level_raw, 0) if level_raw not in (None, '') else None, + 'since': _parse_timestamp(args.get('since')), + 'until': _parse_timestamp(args.get('until')), + }, ) columns = ( diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 05a17cbab..7934f6c3c 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -735,6 +735,79 @@ class RuntimeConnectionHandler(handler.Handler): self.actions[action_name] = secured_action + async def _execution_request(self, action: str, data: dict[str, Any]): + from ..api.application.contracts import ApplicationAPIError, ApplicationAPIException + from ..api.application.execution import ExecutionAPIContext + + action_context = self._require_runtime_action_context() + async with self._tenant_action_scope(action_context): + action_context, identity = await self._require_plugin_action_context() + claimed_identity = data.get('caller_plugin_identity') + if claimed_identity not in {None, f'{identity.plugin_author}/{identity.plugin_name}'}: + raise ApplicationAPIException( + ApplicationAPIError( + 'permission_denied', 'Caller plugin identity does not match the installation binding', 403 + ) + ) + if not isinstance(action_context, InstallationBinding): + action_context = InstallationBinding( + instance_uuid=action_context.instance_uuid, + workspace_uuid=action_context.workspace_uuid, + placement_generation=action_context.placement_generation, + installation_uuid=identity.installation_uuid, + runtime_revision=identity.runtime_revision, + artifact_digest=identity.artifact_digest, + ) + payload = {key: value for key, value in data.items() if key not in _UNTRUSTED_SCOPE_FIELDS} + payload.pop('caller_plugin_identity', None) + run_id = payload.pop('run_id', None) + if action in {'invoke_llm', 'invoke_llm_stream', 'count_tokens'}: + # The SDK already applies this deadline to its transport call. + payload.pop('timeout', None) + # Obsolete policy hints never override the explicit reasoning option. + payload.pop('model_reasoning_overrides', None) + payload.pop('reasoning_config_override', None) + if action == 'call_tool': + # Legacy SDKs send a session projection; Core resolves the scoped query. + payload.pop('session', None) + if action in {'set_binary_storage', 'get_binary_storage', 'delete_binary_storage', 'get_binary_storage_keys'}: + # Storage ownership is derived from the installation, never its wire hint. + payload.pop('owner', None) + return ExecutionAPIContext(action_context, run_id), payload + + def _route_execution_actions(self) -> None: + from ..api.application.contracts import ApplicationAPIException + from ..api.application.execution import ExecutionAPI + + self._execution_actions = self.actions.copy() + self.execution_api = ExecutionAPI(self.ap, handler=self) + for operation in self.execution_api.operations(): + if operation.streaming: + + async def stream(data, *, _operation=operation): + try: + context, payload = await self._execution_request(_operation.action, data) + async with aclosing( + self.execution_api.stream(_operation.operation_id, context, payload) + ) as source: + async for item in source: + yield handler.ActionResponse.success(item) + except ApplicationAPIException as exc: + yield handler.ActionResponse.error(exc.error.message, exc.error.details) + + self.actions[operation.action] = stream + else: + + async def invoke(data, *, _operation=operation): + try: + context, payload = await self._execution_request(_operation.action, data) + result = await self.execution_api.invoke(_operation.operation_id, context, payload) + return handler.ActionResponse.success(result) + except ApplicationAPIException as exc: + return handler.ActionResponse.error(exc.error.message, exc.error.details) + + self.actions[operation.action] = invoke + async def _get_plugin_setting( self, action_context: ActionContext, @@ -2710,6 +2783,7 @@ class RuntimeConnectionHandler(handler.Handler): ) self._secure_plugin_actions() + self._route_execution_actions() @contextlib.contextmanager def installation_scope(self, binding: InstallationBinding | None): diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index f74eb9abe..1ac870799 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -126,8 +126,8 @@ def fake_bot_app(): } ) app.bot_service.create_bot = AsyncMock(return_value='new-bot-uuid') - app.bot_service.update_bot = AsyncMock(return_value={}) - app.bot_service.delete_bot = AsyncMock() + app.bot_service.update_bot = AsyncMock(return_value=None) + app.bot_service.delete_bot = AsyncMock(return_value=None) app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1)) app.bot_service.list_event_route_statuses = AsyncMock( return_value={ @@ -171,11 +171,17 @@ def fake_bot_app(): 'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}], } ) - app.bot_service.send_message = AsyncMock() + app.bot_service.send_message = AsyncMock(return_value=None) # Platform manager app.platform_mgr = Mock() + from tests.utils.application_api import bind_application_api + + app.persistence_mgr.tenant_scope = None + bind_application_api( + app, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) + ) return app @@ -232,10 +238,10 @@ class TestBotEndpoints: called = fake_bot_app.bot_service.create_bot assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ( - {'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'}, - ) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'bot_data': {'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'} + } assert data['data'] == {'uuid': 'new-bot-uuid'} @pytest.mark.asyncio @@ -253,8 +259,8 @@ class TestBotEndpoints: called = fake_bot_app.bot_service.get_runtime_bot_info assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-bot-uuid',) - assert called.await_args.kwargs == {'include_secret': True} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True, 'bot_uuid': 'test-bot-uuid'} assert data['data']['bot'] == { 'uuid': 'test-bot-uuid', 'name': 'Test Bot', @@ -279,8 +285,8 @@ class TestBotEndpoints: called = fake_bot_app.bot_service.update_bot assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-bot-uuid', {'name': 'Updated Bot'}) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'bot_uuid': 'test-bot-uuid', 'bot_data': {'name': 'Updated Bot'}} @pytest.mark.asyncio async def test_delete_bot_success(self, quart_test_client, fake_bot_app): @@ -294,8 +300,8 @@ class TestBotEndpoints: called = fake_bot_app.bot_service.delete_bot assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-bot-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'bot_uuid': 'test-bot-uuid'} assert (await response.get_json())['code'] == 0 @@ -321,8 +327,8 @@ class TestBotLogsEndpoint: called = fake_bot_app.bot_service.list_event_logs assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-bot-uuid', -1, 10) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'bot_uuid': 'test-bot-uuid', 'from_index': -1, 'max_count': 10} assert data['data'] == {'logs': [{'uuid': 'log-1', 'message': 'test log'}], 'total_count': 1} @pytest.mark.asyncio @@ -379,8 +385,8 @@ class TestBotEventRouteDryRunEndpoint: assert data['data']['diagnostic_steps'][0].startswith('Route 1') fake_bot_app.bot_service.dry_run_event_route.assert_awaited_with( ANY, - bot_uuid='test-bot-uuid', - event_type='platform.member.joined', + 'test-bot-uuid', + 'platform.member.joined', event_data={'room': {'id': 'room-1'}}, context={'source': 'ui'}, event_bindings=[ @@ -411,7 +417,7 @@ class TestBotEventRouteStatusEndpoint: assert data['code'] == 0 assert data['data']['routes'][0]['binding_id'] == 'binding-1' assert data['data']['routes'][0]['last_status'] == 'delivered' - fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid') + fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, bot_uuid='test-bot-uuid') @pytest.mark.usefixtures('mock_circular_import_chain') @@ -439,8 +445,13 @@ class TestBotSendMessageEndpoint: called = fake_bot_app.bot_service.send_message called.assert_awaited_once() assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-bot-uuid', 'person', 'user123', [{'type': 'text', 'text': 'Hello'}]) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'bot_uuid': 'test-bot-uuid', + 'target_type': 'person', + 'target_id': 'user123', + 'message_chain_data': [{'type': 'text', 'text': 'Hello'}], + } @pytest.mark.asyncio async def test_send_message_missing_target_type(self, quart_test_client): diff --git a/tests/integration/api/test_box_security.py b/tests/integration/api/test_box_security.py index a0409d908..d48decff7 100644 --- a/tests/integration/api/test_box_security.py +++ b/tests/integration/api/test_box_security.py @@ -50,6 +50,11 @@ async def box_security_api(): application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}]) application.box_service.managed_admission_required = False + from tests.utils.application_api import bind_application_api + + application.workspace_service = SimpleNamespace() + application.workspace_service.get_execution_binding = AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a')) + bind_application_api(application, SimpleNamespace(instance_uuid='instance-a', workspace_uuid=WORKSPACE_UUID, placement_generation=1)) quart_app = quart.Quart(__name__) router = BoxRouterGroup(application, quart_app) await router.initialize() diff --git a/tests/integration/api/test_fresh_oss_workspace_journey.py b/tests/integration/api/test_fresh_oss_workspace_journey.py index 36277d658..4d20e304d 100644 --- a/tests/integration/api/test_fresh_oss_workspace_journey.py +++ b/tests/integration/api/test_fresh_oss_workspace_journey.py @@ -77,6 +77,9 @@ async def test_fresh_oss_workspace_http_journey_uses_real_sqlite_persistence( application.workspace_service, ) application.user_service = UserService(application) + from langbot.pkg.api.application import ApplicationAPI + + application.application_api = ApplicationAPI(application) quart_app = Quart(__name__) await UserRouterGroup(application, quart_app).initialize() diff --git a/tests/integration/api/test_knowledge.py b/tests/integration/api/test_knowledge.py index 7c7beb5d2..9a4d49cb7 100644 --- a/tests/integration/api/test_knowledge.py +++ b/tests/integration/api/test_knowledge.py @@ -118,18 +118,29 @@ def fake_knowledge_app(): } ) app.knowledge_service.create_knowledge_base = AsyncMock(return_value='new-kb-uuid') - app.knowledge_service.update_knowledge_base = AsyncMock(return_value={}) - app.knowledge_service.delete_knowledge_base = AsyncMock() + app.knowledge_service.update_knowledge_base = AsyncMock(return_value=None) + app.knowledge_service.delete_knowledge_base = AsyncMock(return_value=None) app.knowledge_service.get_files_by_knowledge_base = AsyncMock( return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}] ) app.knowledge_service.store_file = AsyncMock(return_value=17) - app.knowledge_service.delete_file = AsyncMock() + app.knowledge_service.delete_file = AsyncMock(return_value=None) app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}]) # RAG manager app.rag_mgr = Mock() + from tests.utils.application_api import bind_application_api + + app.persistence_mgr.tenant_scope = None + bind_application_api( + app, + SimpleNamespace( + instance_uuid='instance-knowledge-api', + workspace_uuid='00000000-0000-0000-0000-00000000000a', + placement_generation=1, + ), + ) return app @@ -198,8 +209,8 @@ class TestKnowledgeBaseEndpoints: called = fake_knowledge_app.knowledge_service.create_knowledge_base assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ({'name': 'New KB', 'engine_plugin_id': 'test/engine'},) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'kb_data': {'name': 'New KB', 'engine_plugin_id': 'test/engine'}} assert data['data'] == {'uuid': 'new-kb-uuid'} @pytest.mark.asyncio @@ -217,8 +228,8 @@ class TestKnowledgeBaseEndpoints: called = fake_knowledge_app.knowledge_service.get_knowledge_base assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid',) - assert called.await_args.kwargs == {'include_secret': True} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True, 'kb_uuid': 'test-kb-uuid'} assert data['data']['base'] == { 'uuid': 'test-kb-uuid', 'name': 'Test Knowledge Base', @@ -242,8 +253,8 @@ class TestKnowledgeBaseEndpoints: called = fake_knowledge_app.knowledge_service.update_knowledge_base assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid', {'name': 'Updated KB'}) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'kb_uuid': 'test-kb-uuid', 'kb_data': {'name': 'Updated KB'}} @pytest.mark.asyncio async def test_delete_knowledge_base_success(self, quart_test_client, fake_knowledge_app): @@ -257,8 +268,8 @@ class TestKnowledgeBaseEndpoints: called = fake_knowledge_app.knowledge_service.delete_knowledge_base assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'kb_uuid': 'test-kb-uuid'} assert (await response.get_json())['code'] == 0 @@ -281,8 +292,8 @@ class TestKnowledgeBaseFilesEndpoints: called = fake_knowledge_app.knowledge_service.get_files_by_knowledge_base assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'kb_uuid': 'test-kb-uuid'} assert data['data']['files'] == [{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}] @pytest.mark.asyncio @@ -302,8 +313,12 @@ class TestKnowledgeBaseFilesEndpoints: called = fake_knowledge_app.knowledge_service.store_file assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-id') - assert called.await_args.kwargs == {'parser_plugin_id': 'test/parser'} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'parser_plugin_id': 'test/parser', + 'kb_uuid': 'test-kb-uuid', + 'file_id': 'test-file-id', + } assert data['data'] == {'task_id': 17} @pytest.mark.asyncio @@ -318,8 +333,8 @@ class TestKnowledgeBaseFilesEndpoints: called = fake_knowledge_app.knowledge_service.delete_file assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-uuid') - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'kb_uuid': 'test-kb-uuid', 'file_id': 'test-file-uuid'} assert (await response.get_json())['code'] == 0 @@ -344,8 +359,12 @@ class TestKnowledgeBaseRetrieveEndpoint: called = fake_knowledge_app.knowledge_service.retrieve_knowledge_base assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' - assert called.await_args.args[1:] == ('test-kb-uuid', 'test query', {'top_k': 5}) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'retrieval_settings': {'top_k': 5}, + 'kb_uuid': 'test-kb-uuid', + 'query': 'test query', + } assert data['data']['results'] == [{'content': 'test result', 'score': 0.95}] @pytest.mark.asyncio diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 88b05cabc..b8bfa48a7 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -135,6 +135,10 @@ def fake_monitoring_app(): app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}]) app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field + from tests.utils.application_api import bind_application_api, _tenant_scope + + app.persistence_mgr.tenant_scope = _tenant_scope + bind_application_api(app, SimpleNamespace(instance_uuid='instance', workspace_uuid='00000000-0000-0000-0000-00000000000a', placement_generation=1)) return app @@ -617,3 +621,22 @@ class TestMonitoringExportEndpoint: assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' assert called.await_args.kwargs['limit'] == 100000 + + +@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.mark.asyncio +@pytest.mark.parametrize('detail', [False, True]) +@pytest.mark.parametrize(('query', 'limit', 'offset'), [('limit=50&offset=2', 50, 2), ('limit=invalid&offset=-5', 100, 0), ('limit=9000&offset=0', 5000, 0)]) +async def test_execution_http_pagination_preserves_normalization( + fake_monitoring_app, quart_test_client, detail, query, limit, offset, +): + service = fake_monitoring_app.monitoring_service + method = 'get_execution_detail' if detail else 'get_executions' + path = '/executions/agent/run-a?section=overview&' if detail else '/executions?' + result = {'events': []} if detail else {'items': [], 'total': 0} + with patch.dict(fake_monitoring_app.instance_config.data, {'monitoring': {'query_limits': {'page_rows': 5000}}}), patch.object(service, method, AsyncMock(return_value=result)) as operation: + response = await quart_test_client.get('/api/v1/monitoring' + path + query, headers={'Authorization': 'Bearer test_token'}) + assert response.status_code == 200 + assert (await response.get_json())['data'] == result + assert operation.await_args.kwargs['limit'] == limit + assert operation.await_args.kwargs['offset'] == offset diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index db1ec1d54..ac0c36206 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -129,8 +129,8 @@ def fake_pipeline_app(): } ) app.pipeline_service.create_pipeline = AsyncMock(return_value='new-pipeline-uuid') - app.pipeline_service.update_pipeline = AsyncMock(return_value={}) - app.pipeline_service.delete_pipeline = AsyncMock() + app.pipeline_service.update_pipeline = AsyncMock(return_value=None) + app.pipeline_service.delete_pipeline = AsyncMock(return_value=None) app.pipeline_service.copy_pipeline = AsyncMock(return_value='copied-pipeline-uuid') # Bot service @@ -158,6 +158,16 @@ def fake_pipeline_app(): # Plugin connector (for extensions endpoint) app.plugin_connector.list_plugins = AsyncMock(return_value=[]) + from tests.utils.application_api import bind_application_api + + app.persistence_mgr.tenant_scope = None + bind_application_api( + app, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) + ) + from langbot.pkg.api.http.service.pipeline import PipelineService + + app.pipeline_service.ap = app + app.pipeline_service.get_pipeline_extensions = PipelineService.get_pipeline_extensions.__get__(app.pipeline_service) return app @@ -227,8 +237,8 @@ class TestPipelinesListEndpoint: called = fake_pipeline_app.pipeline_service.get_pipelines assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('created_at', 'DESC') - assert called.await_args.kwargs == {'include_secret': True} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'sort_by': 'created_at', 'sort_order': 'DESC', 'include_secret': True} assert data['data']['pipelines'] == [ { 'uuid': 'test-pipeline-uuid', @@ -254,8 +264,8 @@ class TestPipelinesListEndpoint: called = fake_pipeline_app.pipeline_service.get_pipelines assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('name', 'ASC') - assert called.await_args.kwargs == {'include_secret': True} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'sort_by': 'name', 'sort_order': 'ASC', 'include_secret': True} @pytest.mark.usefixtures('mock_circular_import_chain') @@ -277,8 +287,8 @@ class TestPipelinesCRUDEndpoints: called = fake_pipeline_app.pipeline_service.get_pipeline assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-pipeline-uuid',) - assert called.await_args.kwargs == {'include_secret': True} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True, 'pipeline_uuid': 'test-pipeline-uuid'} assert data['data']['pipeline'] == {'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', 'config': {}} @pytest.mark.asyncio @@ -298,8 +308,8 @@ class TestPipelinesCRUDEndpoints: called = fake_pipeline_app.pipeline_service.create_pipeline assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ({'name': 'New Pipeline', 'config': {}},) - assert called.await_args.kwargs == {'default': False} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'default': False, 'pipeline_data': {'name': 'New Pipeline', 'config': {}}} assert data['data'] == {'uuid': 'new-pipeline-uuid'} @pytest.mark.asyncio @@ -315,8 +325,10 @@ class TestPipelinesCRUDEndpoints: assert response.status_code == 200 call = fake_pipeline_app.pipeline_service.create_pipeline.await_args - assert call.kwargs == {'default': True} - assert call.args[1]['is_default'] is True + assert call.kwargs == { + 'default': True, + 'pipeline_data': {'name': 'Default Pipeline', 'config': {}, 'is_default': True}, + } @pytest.mark.asyncio async def test_update_pipeline_success(self, quart_test_client, fake_pipeline_app): @@ -334,8 +346,11 @@ class TestPipelinesCRUDEndpoints: called = fake_pipeline_app.pipeline_service.update_pipeline assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-pipeline-uuid', {'name': 'Updated Pipeline'}) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'pipeline_uuid': 'test-pipeline-uuid', + 'pipeline_data': {'name': 'Updated Pipeline'}, + } @pytest.mark.asyncio async def test_delete_pipeline_success(self, quart_test_client, fake_pipeline_app): @@ -351,8 +366,8 @@ class TestPipelinesCRUDEndpoints: called = fake_pipeline_app.pipeline_service.delete_pipeline assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-pipeline-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'pipeline_uuid': 'test-pipeline-uuid'} @pytest.mark.asyncio async def test_copy_pipeline_success(self, quart_test_client, fake_pipeline_app): @@ -369,8 +384,8 @@ class TestPipelinesCRUDEndpoints: called = fake_pipeline_app.pipeline_service.copy_pipeline assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-pipeline-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'pipeline_uuid': 'test-pipeline-uuid'} assert data['data'] == {'uuid': 'copied-pipeline-uuid'} diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index 2f773595a..d03262c49 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -10,6 +10,8 @@ from unittest.mock import AsyncMock, Mock, call import pytest import quart from quart.datastructures import FileStorage +from langbot.pkg.plugin.management import redact_plugin_secrets, restore_plugin_secret_placeholders +from tests.utils.application_api import bind_application_api pytestmark = pytest.mark.integration @@ -122,6 +124,14 @@ async def plugin_security_api(plugin_module): persistence_result.scalar_one_or_none.return_value = RAW_CONFIG application.persistence_mgr.execute_async = AsyncMock(return_value=persistence_result) application.persistence_mgr.tenant_uow = None + application.persistence_mgr.tenant_scope = None + application.workspace_service = None + application.workspace_settings_service = None + application.operation_trace_active = False + bind_application_api( + application, + SimpleNamespace(instance_uuid='instance-test', workspace_uuid=WORKSPACE_UUID, placement_generation=1), + ) quart_app = quart.Quart(__name__) router = plugin_module.PluginsRouterGroup(application, quart_app) @@ -137,7 +147,7 @@ def _headers(token: str) -> dict[str, str]: def test_recursive_redaction_preserves_structure_without_mutating_input(plugin_module): - redacted = plugin_module.redact_plugin_secrets(RAW_CONFIG) + redacted = redact_plugin_secrets(RAW_CONFIG) assert redacted['apiKey'] == '***' assert redacted['nested']['headers'] == { @@ -154,7 +164,7 @@ def test_recursive_redaction_preserves_structure_without_mutating_input(plugin_m assert RAW_CONFIG['apiKey'] == 'api-secret' assert RAW_CONFIG['nested']['headers']['Authorization'] == 'Bearer nested-secret' with pytest.raises(ValueError, match='no existing value'): - plugin_module.restore_plugin_secret_placeholders({'api_key': '***'}, {}) + restore_plugin_secret_placeholders({'api_key': '***'}, {}) @pytest.mark.asyncio @@ -197,7 +207,7 @@ async def test_manager_read_is_redacted_but_write_preserves_or_replaces_secrets( '/api/v1/plugins/example/secure-plugin/config', headers=_headers('manager-token'), ) - masked_update = plugin_module.redact_plugin_secrets(RAW_CONFIG) + masked_update = redact_plugin_secrets(RAW_CONFIG) masked_update['enabled'] = False preserved_write = await client.put( '/api/v1/plugins/example/secure-plugin/config', @@ -293,17 +303,17 @@ async def test_github_install_rejects_internal_asset_url_before_task_creation( @pytest.mark.asyncio -async def test_local_install_forwards_explicit_administrator_force(plugin_security_api): +@pytest.mark.parametrize('stale_generation', [False, True]) +async def test_local_install_forwards_explicit_administrator_force(plugin_security_api, stale_generation): application, client, _ = plugin_security_api execution_context = SimpleNamespace( instance_uuid='instance-test', workspace_uuid=WORKSPACE_UUID, placement_generation=1, ) - application.persistence_mgr.tenant_scope = None application.plugin_connector.require_workspace_context = AsyncMock(return_value=execution_context) application.plugin_connector.install_plugin = AsyncMock() - application.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id='task-certification')) + application.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=17)) response = await client.post( '/api/v1/plugins/install/local', @@ -316,8 +326,36 @@ async def test_local_install_forwards_explicit_administrator_force(plugin_securi assert response.status_code == 200 operation = application.task_mgr.create_user_task.call_args.args[0] + if stale_generation: + from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError + + application.plugin_connector.require_workspace_context.side_effect = WorkspaceGenerationMismatchError('stale') + with pytest.raises(WorkspaceGenerationMismatchError): + await operation + application.plugin_connector.install_plugin.assert_not_awaited() + return await operation assert application.plugin_connector.install_plugin.await_args.args[1] == { 'plugin_file': b'archive', 'administrator_force': True, } + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['icon', 'assets']) +async def test_authenticated_plugin_media_preserves_bytes_mime_and_missing_response(plugin_security_api, kind): + application, client, _ = plugin_security_api + method = 'get_plugin_icon' if kind == 'icon' else 'get_plugin_assets' + value_key = 'plugin_icon_base64' if kind == 'icon' else 'asset_base64' + reader = AsyncMock(return_value={value_key: 'AP8K', 'mime_type': 'image/png'}) + setattr(application.plugin_connector, method, reader) + suffix = 'authenticated-icon' if kind == 'icon' else 'authenticated-assets/image.png' + path = '/api/v1/plugins/example/secure-plugin/' + suffix + response = await client.get(path, headers=_headers('viewer-token')) + assert response.status_code == 200 + assert response.mimetype == 'image/png' + assert await response.get_data() == b'\x00\xff\n' + reader.side_effect = ValueError('Plugin example/secure-plugin is not installed in this Workspace') + missing = await client.get(path, headers=_headers('viewer-token')) + assert missing.status_code == 404 + assert await missing.get_data(as_text=True) == ('Icon not found' if kind == 'icon' else 'Asset not found') diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py index cc01466b4..3283d0acc 100644 --- a/tests/integration/api/test_providers.py +++ b/tests/integration/api/test_providers.py @@ -99,8 +99,8 @@ def fake_provider_app(): return_value={'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'} ) app.provider_service.create_provider = AsyncMock(return_value='new-provider-uuid') - app.provider_service.update_provider = AsyncMock(return_value={}) - app.provider_service.delete_provider = AsyncMock() + app.provider_service.update_provider = AsyncMock(return_value=None) + app.provider_service.delete_provider = AsyncMock(return_value=None) app.provider_service.get_provider_model_counts = AsyncMock( return_value={'llm_count': 2, 'embedding_count': 1, 'rerank_count': 0} ) @@ -110,9 +110,9 @@ def fake_provider_app(): app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}]) app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'}) app.llm_model_service.create_llm_model = AsyncMock(return_value='new-model-uuid') - app.llm_model_service.update_llm_model = AsyncMock(return_value={}) - app.llm_model_service.delete_llm_model = AsyncMock() - app.llm_model_service.test_llm_model = AsyncMock() + app.llm_model_service.update_llm_model = AsyncMock(return_value=None) + app.llm_model_service.delete_llm_model = AsyncMock(return_value=None) + app.llm_model_service.test_llm_model = AsyncMock(return_value=None) # Embedding model service app.embedding_models_service = Mock() @@ -120,19 +120,25 @@ def fake_provider_app(): return_value=[{'uuid': 'embedding-model', 'name': 'Embedding'}] ) app.embedding_models_service.create_embedding_model = AsyncMock(return_value='new-embedding-uuid') - app.embedding_models_service.test_embedding_model = AsyncMock() + app.embedding_models_service.test_embedding_model = AsyncMock(return_value=None) # Rerank model service app.rerank_models_service = Mock() app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[{'uuid': 'rerank-model', 'name': 'Rerank'}]) app.rerank_models_service.create_rerank_model = AsyncMock(return_value='new-rerank-uuid') - app.rerank_models_service.test_rerank_model = AsyncMock() + app.rerank_models_service.test_rerank_model = AsyncMock(return_value=None) # Model manager app.model_mgr = Mock() app.model_mgr.load_provider = AsyncMock() app.model_mgr.unload_provider = AsyncMock() + from tests.utils.application_api import bind_application_api + + app.persistence_mgr.tenant_scope = None + bind_application_api( + app, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) + ) return app @@ -231,8 +237,11 @@ class TestProviderEndpoints: called = fake_provider_app.provider_service.update_provider assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-provider-uuid', {'name': 'Updated Provider'}) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'provider_uuid': 'test-provider-uuid', + 'provider_data': {'name': 'Updated Provider'}, + } @pytest.mark.asyncio async def test_delete_provider_success(self, quart_test_client, fake_provider_app): @@ -246,8 +255,8 @@ class TestProviderEndpoints: called = fake_provider_app.provider_service.delete_provider assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-provider-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'cascade': False, 'provider_uuid': 'test-provider-uuid'} assert (await response.get_json())['code'] == 0 @pytest.mark.asyncio @@ -269,8 +278,8 @@ class TestProviderEndpoints: called = fake_provider_app.provider_service.get_provider_model_counts assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-provider-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'provider_uuid': 'test-provider-uuid'} assert {key: provider_data[key] for key in ('llm_count', 'embedding_count', 'rerank_count')} == { 'llm_count': 2, 'embedding_count': 1, @@ -315,8 +324,8 @@ class TestModelEndpoints: called = fake_provider_app.llm_model_service.get_llm_model assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-model-uuid',) - assert called.await_args.kwargs == {'include_secret': True} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True, 'model_uuid': 'test-model-uuid'} assert data['data']['model'] == {'uuid': 'test-model-uuid', 'name': 'gpt-4'} @pytest.mark.asyncio @@ -336,8 +345,11 @@ class TestModelEndpoints: called = fake_provider_app.llm_model_service.create_llm_model assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ({'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'auto_set_to_default_pipeline': True, + 'model_data': {'name': 'New Model', 'provider_uuid': 'test-provider-uuid'}, + } assert data['data'] == {'uuid': 'new-model-uuid'} @pytest.mark.asyncio @@ -352,8 +364,8 @@ class TestModelEndpoints: called = fake_provider_app.llm_model_service.delete_llm_model assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ('test-model-uuid',) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'model_uuid': 'test-model-uuid'} assert (await response.get_json())['code'] == 0 @pytest.mark.asyncio @@ -434,8 +446,10 @@ class TestEmbeddingModelEndpoints: called = fake_provider_app.embedding_models_service.create_embedding_model assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ({'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'model_data': {'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'} + } assert data['data'] == {'uuid': 'new-embedding-uuid'} @@ -479,6 +493,8 @@ class TestRerankModelEndpoints: called = fake_provider_app.rerank_models_service.create_rerank_model assert called.await_count == 1 assert called.await_args.args[0].workspace_uuid == 'workspace-test' - assert called.await_args.args[1:] == ({'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},) - assert called.await_args.kwargs == {} + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == { + 'model_data': {'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'} + } assert data['data'] == {'uuid': 'new-rerank-uuid'} diff --git a/tests/integration/api/test_skills_entitlements.py b/tests/integration/api/test_skills_entitlements.py index 0df480e27..96fd87e0f 100644 --- a/tests/integration/api/test_skills_entitlements.py +++ b/tests/integration/api/test_skills_entitlements.py @@ -33,6 +33,12 @@ async def skills_api(): return_value=[{'name': 'docs-only', 'description': 'No execution required'}] ) + from tests.utils.application_api import bind_application_api + + application.workspace_service = None + bind_application_api( + application, SimpleNamespace(instance_uuid='instance-a', workspace_uuid=WORKSPACE_UUID, placement_generation=1) + ) quart_app = quart.Quart(__name__) router = SkillsRouterGroup(application, quart_app) await router.initialize() diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 924f83cb4..9579289d0 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -35,6 +35,8 @@ from langbot.pkg.persistence.mgr import PersistenceManager from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService from langbot.pkg.workspace.service import WorkspaceService from langbot.pkg.workspace.policy import CloudWorkspacePolicy +from langbot.pkg.api.application import ApplicationAPI +from langbot.pkg.operation_trace.routes import SettingsRouterGroup pytestmark = [pytest.mark.integration, pytest.mark.asyncio] @@ -70,6 +72,7 @@ async def workspace_api(tmp_path): ) application.user_service = UserService(application) application.apikey_service = ApiKeyService(application) + application.application_api = ApplicationAPI(application) quart_app = Quart(__name__) await WorkspacesRouterGroup(application, quart_app).initialize() @@ -77,6 +80,7 @@ async def workspace_api(tmp_path): await ApiKeysRouterGroup(application, quart_app).initialize() await UserRouterGroup(application, quart_app).initialize() await SystemRouterGroup(application, quart_app).initialize() + await SettingsRouterGroup(application, quart_app).initialize() client = quart_app.test_client() init_response = await client.post( @@ -1003,3 +1007,91 @@ async def test_workspace_language_persists_and_controls_api_errors(workspace_api # An untrusted selector never causes us to read another tenant's language. unauthorized = await client.get('/api/v1/workspaces/current/language', headers=_auth(owner_token, 'language-other')) assert unauthorized.status_code in (403, 404) + + +async def test_wizard_progress_roundtrip_uses_webui_fields(workspace_api): + application, client, _, token = workspace_api + application.application_api.execute = AsyncMock(wraps=application.application_api.execute) + progress = { + 'step': 3, + 'selected_scenario': None, + 'selected_adapter': 'http', + 'created_bot_uuid': 'bot-a', + 'created_pipeline_uuid': 'pipeline-a', + 'bot_saved': True, + 'message_received': True, + 'selected_runner': 'plugin:langbot-team/LocalAgent/default', + } + saved = await client.put('/api/v1/system/wizard/progress', headers=_auth(token), json=progress) + assert saved.status_code == 200 + info = await client.get('/api/v1/system/info', headers=_auth(token)) + assert (await info.get_json())['data']['wizard_progress'] == progress + completed = await client.post('/api/v1/system/wizard/completed', headers=_auth(token), json={'status': 'completed'}) + assert completed.status_code == 200 + info = await client.get('/api/v1/system/info', headers=_auth(token)) + data = (await info.get_json())['data'] + assert data['wizard_status'] == 'completed' + assert data['wizard_progress'] is None + assert {'system.wizard.progress.update', 'system.wizard.get', 'system.wizard.complete'} <= { + call.args[0] for call in application.application_api.execute.await_args_list + } + + +async def test_migrated_workspace_mutations_record_one_rich_audit_each(workspace_api, monkeypatch): + from langbot.pkg.operation_trace import service as tracing + from langbot.pkg.entity.persistence.operation_log import WorkspaceOperationLog + + application, client, engine, token = workspace_api + monkeypatch.setattr(tracing, '_global_enabled', False) + service = application.workspace_settings_service = tracing.WorkspaceSettingsService(application) + current = await client.get('/api/v1/workspaces/current', headers=_auth(token)) + workspace_uuid = (await current.get_json())['data']['workspace']['uuid'] + changed = await client.put( + '/api/v1/settings/governance', + headers={**_auth(token), 'X-Request-Id': 'governance-audit'}, + json={'level': tracing.OPERATION_LEVEL_MUTATION, 'dedupe_window_seconds': 0}, + ) + assert changed.status_code == 200 + invited = await client.post( + f'/api/v1/workspaces/{workspace_uuid}/invitations', + headers={**_auth(token), 'X-Request-Id': 'invitation-audit'}, + json={'email': 'audited@example.com', 'role': 'viewer'}, + ) + assert invited.status_code == 200 + invitation = (await invited.get_json())['data'] + account, _ = await application.user_service.register_invited_account( + invitation['token'], 'audited@example.com', 'audit-password' + ) + updated = await client.patch( + f'/api/v1/workspaces/{workspace_uuid}/members/{account.uuid}', + headers={**_auth(token), 'X-Request-Id': 'member-audit'}, + json={'role': 'developer'}, + ) + assert updated.status_code == 200 + await service.flush_pending() + async with engine.connect() as connection: + rows = ( + await connection.execute( + sqlalchemy.select(WorkspaceOperationLog).where( + WorkspaceOperationLog.request_id.in_(['governance-audit', 'invitation-audit', 'member-audit']) + ) + ) + ).all() + assert len(rows) == 3 + by_request = {row.request_id: row for row in rows} + assert by_request['governance-audit'].action == 'settings_update' + assert by_request['invitation-audit'].action == 'member_invite' + assert by_request['member-audit'].action == 'member_role_update' + assert json.loads(by_request['member-audit'].changes) == [ + {'field': 'role', 'before': 'viewer', 'after': 'developer'} + ] + assert json.loads(by_request['member-audit'].detail)['member_email'] == 'audited@example.com' + logs = await client.get('/api/v1/settings/operation-logs?action=member_role_update', headers=_auth(token)) + assert logs.status_code == 200 + assert len((await logs.get_json())['data']['records']) == 1 + exported = await client.get( + '/api/v1/settings/operation-logs/export?action=member_role_update', headers=_auth(token) + ) + assert exported.status_code == 200 + assert 'text/csv' in exported.content_type + assert 'member_role_update' in await exported.get_data(as_text=True) diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 6821b3bea..8324f6bf9 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -2202,6 +2202,9 @@ class TestPostgreSQLTenantRuntime: ) ) await WebhookRouterGroup(runtime_application, quart_app).initialize() + from langbot.pkg.api.application import ApplicationAPI + + runtime_application.application_api = ApplicationAPI(runtime_application) await SystemRouterGroup(runtime_application, quart_app).initialize() client = quart_app.test_client() account_a = await runtime_application.user_service.get_user_by_uuid(account_a_uuid) diff --git a/tests/unit_tests/agent/test_history_event_api_auth.py b/tests/unit_tests/agent/test_history_event_api_auth.py index dc18ee2ea..c38ec56b3 100644 --- a/tests/unit_tests/agent/test_history_event_api_auth.py +++ b/tests/unit_tests/agent/test_history_event_api_auth.py @@ -69,7 +69,7 @@ async def _register_session( run_id='run_1', conversation_id='conv_1', bot_id=None, - workspace_id=None, + workspace_id='workspace-test', thread_id=None, available_apis=None, ): @@ -181,6 +181,7 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi await _register_session(session_registry, available_apis={'event_get': True}) store = EventLogStore(db_engine) event_id = await store.append_event( + workspace_id='workspace-test', event_id='evt_projection_1', event_type='message.received', source='platform', @@ -216,11 +217,16 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi @pytest.mark.asyncio @pytest.mark.parametrize( ('denial', 'message'), - [('missing_run', 'run_id is required'), ('expired_run', 'not found or expired'), ('capability', 'not authorized')], + [ + ('missing_run', 'requires a trusted run context'), + ('expired_run', 'does not belong to this installation'), + ('capability', 'not authorized'), + ], ) async def test_event_get_requires_active_run_capability(session_registry, db_engine, denial, message): await _register_session(session_registry, available_apis={'event_get': denial != 'capability'}) await EventLogStore(db_engine).append_event( + workspace_id='workspace-test', event_id='evt_denied', event_type='message.received', source='platform', @@ -269,6 +275,7 @@ async def test_event_page_returns_sdk_page_projection(session_registry, db_engin await _register_session(session_registry, available_apis={'event_page': True}) store = EventLogStore(db_engine) await store.append_event( + workspace_id='workspace-test', event_id='evt_projection_page_1', event_type='message.received', source='platform', @@ -309,6 +316,7 @@ async def test_history_page_filters_run_scope_thread_and_bot(session_registry, d ) store = TranscriptStore(db_engine) await store.append_transcript( + workspace_id='workspace-test', transcript_id='tr_visible', event_id='evt_visible', conversation_id='conv_1', @@ -318,6 +326,7 @@ async def test_history_page_filters_run_scope_thread_and_bot(session_registry, d content='visible', ) await store.append_transcript( + workspace_id='workspace-test', transcript_id='tr_other_bot', event_id='evt_other_bot', conversation_id='conv_1', @@ -327,6 +336,7 @@ async def test_history_page_filters_run_scope_thread_and_bot(session_registry, d content='hidden bot', ) await store.append_transcript( + workspace_id='workspace-test', transcript_id='tr_other_thread', event_id='evt_other_thread', conversation_id='conv_1', @@ -359,6 +369,7 @@ async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_ ) store = EventLogStore(db_engine) await store.append_event( + workspace_id='workspace-test', event_id='evt_visible', event_type='message.received', source='platform', @@ -367,6 +378,7 @@ async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_ thread_id='thread_1', ) await store.append_event( + workspace_id='workspace-test', event_id='evt_other_bot', event_type='message.received', source='platform', @@ -375,6 +387,7 @@ async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_ thread_id='thread_1', ) await store.append_event( + workspace_id='workspace-test', event_id='evt_other_thread', event_type='message.received', source='platform', diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py index 62d390326..f9dadc1ca 100644 --- a/tests/unit_tests/agent/test_run_ledger_api_auth.py +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -117,7 +117,7 @@ async def _register_session( resources=make_resources(), conversation_id=conversation_id, bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', thread_id=None, available_apis=available_apis or {}, ) @@ -129,7 +129,7 @@ async def _create_run( run_id='run_1', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', thread_id=None, plugin_identity='test/runner', runner_id='plugin:test/runner/default', @@ -797,7 +797,7 @@ async def test_runtime_admin_can_register_list_and_claim_with_own_run_session(se runner_id='plugin:other/runner/default', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', priority=5, @@ -860,7 +860,7 @@ async def test_runtime_admin_can_register_list_and_claim_without_run_id(db_engin runner_id='plugin:other/runner/default', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', priority=5, @@ -1373,7 +1373,7 @@ async def test_run_claim_renew_and_release_actions(session_registry, db_engine): runner_id='plugin:test/runner/default', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', priority=5, @@ -1444,7 +1444,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation( runner_id='plugin:other/runner/default', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', priority=30, @@ -1456,7 +1456,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation( runner_id='plugin:test/runner/default', conversation_id='conv_other', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', priority=20, @@ -1468,7 +1468,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation( runner_id='plugin:test/runner/default', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', priority=10, @@ -1565,7 +1565,7 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_ runner_id='plugin:test/runner/default', conversation_id='conv_1', bot_id='bot_1', - workspace_id='workspace_1', + workspace_id='workspace-test', status='queued', queue_name='default', ) diff --git a/tests/unit_tests/agent/test_state_api_auth.py b/tests/unit_tests/agent/test_state_api_auth.py index e311baad3..5ad399d06 100644 --- a/tests/unit_tests/agent/test_state_api_auth.py +++ b/tests/unit_tests/agent/test_state_api_auth.py @@ -60,9 +60,11 @@ class RuntimeConnectionHandler(HostRuntimeConnectionHandler): @pytest.fixture -def session_registry(): +def session_registry(monkeypatch): """Create a fresh session registry for each test.""" - return AgentRunSessionRegistry() + registry = AgentRunSessionRegistry() + monkeypatch.setattr('langbot.pkg.agent.runner.session_registry._global_registry', registry) + return registry @pytest.fixture @@ -111,7 +113,7 @@ class TestStateAPIHandlerAuthorization: result = await state_get_handler({'scope': 'conversation', 'key': 'test_key'}) assert result.code != 0 - assert 'run_id is required' in result.message + assert 'requires a trusted run context' in result.message @pytest.mark.asyncio async def test_state_get_run_not_found_returns_error(self, session_registry, db_engine, persistent_store): @@ -136,7 +138,7 @@ class TestStateAPIHandlerAuthorization: ) assert result.code != 0 - assert 'not found' in result.message.lower() + assert 'does not belong to this installation' in result.message.lower() @pytest.mark.asyncio async def test_state_get_uses_installation_identity_when_payload_omits_caller( @@ -151,6 +153,7 @@ class TestStateAPIHandlerAuthorization: # Register session with plugin_identity await session_registry.register( + workspace_id='workspace-test', run_id='run_test_missing_identity', runner_id='plugin:test/runner/default', query_id=1, @@ -191,6 +194,7 @@ class TestStateAPIHandlerAuthorization: fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) await session_registry.register( + workspace_id='workspace-test', run_id='run_test_mismatch', runner_id='plugin:test/runner/default', query_id=1, @@ -230,6 +234,7 @@ class TestStateAPIHandlerAuthorization: fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) await session_registry.register( + workspace_id='workspace-test', run_id='run_test_disabled', runner_id='plugin:test/runner/default', query_id=1, @@ -268,6 +273,7 @@ class TestStateAPIHandlerAuthorization: fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) await session_registry.register( + workspace_id='workspace-test', run_id='run_test_scope_disabled', runner_id='plugin:test/runner/default', query_id=1, @@ -310,6 +316,7 @@ class TestStateAPIHandlerAuthorization: fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) await session_registry.register( + workspace_id='workspace-test', run_id='run_test_no_scope_key', runner_id='plugin:test/runner/default', query_id=1, @@ -353,6 +360,7 @@ class TestStateAPIFullFlowWithRealDB: # Register session await session_registry.register( + workspace_id='workspace-test', run_id='run_full_flow', runner_id='plugin:test/runner/default', query_id=1, @@ -474,6 +482,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot: # Register with explicit state_policy in the authorization snapshot await session_registry.register( + workspace_id='workspace-test', run_id='run_policy_top_level', runner_id='plugin:test/runner/default', query_id=1, @@ -522,6 +531,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot: # Register with explicit state_context in the authorization snapshot await session_registry.register( + workspace_id='workspace-test', run_id='run_context_top_level', runner_id='plugin:test/runner/default', query_id=1, @@ -571,6 +581,7 @@ class TestResourcesDoesNotContainStateMetadata: resources = make_resources() await session_registry.register( + workspace_id='workspace-test', run_id='run_resources_clean', runner_id='plugin:test/runner/default', query_id=1, diff --git a/tests/unit_tests/api/service/test_cli_diagnostics.py b/tests/unit_tests/api/service/test_cli_diagnostics.py index a78965f5b..028ae81fb 100644 --- a/tests/unit_tests/api/service/test_cli_diagnostics.py +++ b/tests/unit_tests/api/service/test_cli_diagnostics.py @@ -60,6 +60,9 @@ async def diagnostic_client(ap): permissions=ctx.workspace.permissions, ) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, request_context()) ap.apikey_service = SimpleNamespace(authenticate_api_key=authenticate) app = quart.Quart(__name__) for cls in (MonitoringRouterGroup, BoxRouterGroup, PipelinesRouterGroup): diff --git a/tests/unit_tests/api/service/test_provider_cascade.py b/tests/unit_tests/api/service/test_provider_cascade.py index 971dafc3b..044b3511e 100644 --- a/tests/unit_tests/api/service/test_provider_cascade.py +++ b/tests/unit_tests/api/service/test_provider_cascade.py @@ -297,7 +297,10 @@ async def route_app(deletion): ), ) ) - ap.provider_service = SimpleNamespace(delete_provider=AsyncMock()) + ap.provider_service = SimpleNamespace(delete_provider=AsyncMock(return_value=None)) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance', workspace_uuid='a', placement_generation=1)) app = quart.Quart(__name__) await ModelProvidersRouterGroup(ap, app).initialize() return app.test_client(), ap.provider_service.delete_provider, membership @@ -316,8 +319,7 @@ async def test_route_passes_explicit_cascade_and_trusted_workspace(route_app, qu assert response.status_code == 200 assert delete.await_count == 1 assert delete.await_args.args[0].workspace_uuid == 'a' - assert delete.await_args.args[1] == 'target' - assert delete.await_args.kwargs == ({} if expected is None else {'cascade': expected}) + assert delete.await_args.kwargs == {'provider_uuid': 'target', 'cascade': False if expected is None else expected} @pytest.mark.parametrize( diff --git a/tests/unit_tests/api/service/test_runner_extension_boundary.py b/tests/unit_tests/api/service/test_runner_extension_boundary.py index 34cb33f20..3356b02ec 100644 --- a/tests/unit_tests/api/service/test_runner_extension_boundary.py +++ b/tests/unit_tests/api/service/test_runner_extension_boundary.py @@ -9,8 +9,10 @@ import sqlalchemy as sa from langbot.pkg.api.http.service.pipeline import PipelineService from langbot.pkg.entity.persistence.pipeline import LegacyPipeline +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata from langbot.pkg.workspace.errors import WorkspaceNotFoundError from tests.unit_tests.api.service.test_pipeline_migration import env, context, RID # noqa: F401 +from tests.utils.application_api import bind_application_api @pytest.mark.asyncio @@ -27,6 +29,7 @@ async def test_http_and_mcp_share_real_persistence_without_expanding_extensions( env.ap.pipeline_mgr.load_pipeline = AsyncMock() env.ap.pipeline_mgr.remove_pipeline = AsyncMock() env.ap.pipeline_service = PipelineService(env.ap) + bind_application_api(env.ap, context()) app = Quart(__name__) routes = PipelinesRouterGroup(env.ap, app) monkeypatch.setattr(routes, '_authenticate_api_key', AsyncMock(return_value=context())) @@ -43,6 +46,7 @@ async def test_http_and_mcp_share_real_persistence_without_expanding_extensions( } prefs = {'enable_all_plugins': False, 'plugins': [], 'enable_all_mcp_servers': False, 'mcp_servers': []} async with env.engine.begin() as conn: + await conn.run_sync(lambda connection: WorkspaceMetadata.__table__.create(connection, checkfirst=True)) await conn.execute( sa.update(LegacyPipeline) .where(LegacyPipeline.uuid == 'one') diff --git a/tests/unit_tests/api/test_adapter_session_scoping.py b/tests/unit_tests/api/test_adapter_session_scoping.py index e6bf6ca6c..9ae47f909 100644 --- a/tests/unit_tests/api/test_adapter_session_scoping.py +++ b/tests/unit_tests/api/test_adapter_session_scoping.py @@ -110,6 +110,9 @@ async def _create_client(*, role: str = 'developer'): platform_mgr=SimpleNamespace(), task_mgr=TestTaskManager(), ) + from tests.utils.application_api import bind_application_api + + bind_application_api(application, _request_context()) router = AdaptersRouterGroup(application, quart_app) await router.initialize() return quart_app.test_client() diff --git a/tests/unit_tests/api/test_agents_controller.py b/tests/unit_tests/api/test_agents_controller.py index 54b092e7b..349520d8b 100644 --- a/tests/unit_tests/api/test_agents_controller.py +++ b/tests/unit_tests/api/test_agents_controller.py @@ -46,6 +46,9 @@ async def _create_test_client(agent_service: SimpleNamespace): apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None)), workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)), ) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1)) AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup group = AgentsRouterGroup(ap, app) await group.initialize() @@ -67,7 +70,7 @@ async def test_create_agent_returns_bad_request_for_invalid_runner_config(): assert await response.get_json() == {'code': -1, 'msg': message} agent_service.create_agent.assert_awaited_once_with( ANY, - {'name': 'Invalid Agent', 'config': {'runner_config': []}}, + agent_data={'name': 'Invalid Agent', 'config': {'runner_config': []}}, ) @@ -86,8 +89,8 @@ async def test_update_agent_returns_bad_request_for_invalid_runner_config(): assert await response.get_json() == {'code': -1, 'msg': message} agent_service.update_agent.assert_awaited_once_with( ANY, - 'agent-1', - {'config': {'runner': {'id': 7}}}, + agent_uuid='agent-1', + agent_data={'config': {'runner': {'id': 7}}}, ) @@ -117,8 +120,8 @@ async def test_debug_agent_executes_with_runtime_permission(): assert (await response.get_json())['data'] == result agent_service.debug_agent.assert_awaited_once_with( ANY, - 'agent-1', - { + agent_uuid='agent-1', + payload={ 'event_type': 'message.received', 'text': 'hello', 'data': {}, @@ -204,7 +207,16 @@ async def test_debug_stream_cancels_execution_when_closed(): finally: cancelled.set() - response = debug_stream_response(SimpleNamespace(debug_agent=debug_agent), None, 'agent-1', {}) + from langbot.pkg.api.http.controller.group import RouterGroup + from tests.integration.api.test_application_foundation import context + from tests.utils.application_api import bind_application_api + + ap = SimpleNamespace(agent_service=SimpleNamespace(debug_agent=debug_agent)) + request_context = context() + bind_application_api(ap, request_context) + router = SimpleNamespace(ap=ap) + router.stream_application = RouterGroup.stream_application.__get__(router) + response = debug_stream_response(router, request_context, 'agent-1', {}) iterator = response.response.__aiter__() first = await anext(iterator) assert 'message.delta' in first diff --git a/tests/unit_tests/api/test_application_plugins.py b/tests/unit_tests/api/test_application_plugins.py index 78d601575..45892a387 100644 --- a/tests/unit_tests/api/test_application_plugins.py +++ b/tests/unit_tests/api/test_application_plugins.py @@ -48,18 +48,38 @@ async def test_plugin_reads_mask_secrets_and_config_write_restores_persisted_val assert (await ops.get_plugin(ap, CONTEXT, key))['plugin']['plugin_config']['apiKey'] == '***' assert raw['plugin_config']['apiKey'] == 'runtime-secret' assert (await ops.get_config(ap, CONTEXT, key))['config']['nested']['token'] == '***' - await ops.update_config( + result = await ops.update_config( ap, CONTEXT, ops.ConfigInput( author='a', plugin_name='b', config={'apiKey': '***', 'nested': {'token': '***'}, 'enabled': True} ), ) + assert result == {'changes': [{'field': 'enabled', 'before': False, 'after': True}]} assert ap.plugin_connector.set_plugin_config.await_args.args == ( 'a', 'b', {'apiKey': 'stored-secret', 'nested': {'token': 'nested-secret'}, 'enabled': True}, ) + changed = await ops.update_config( + ap, + CONTEXT, + ops.ConfigInput( + author='a', + plugin_name='b', + config={ + 'apiKey': 'replacement-secret', + 'nested': {'token': 'changed-secret', 'mode': 'new'}, + 'enabled': False, + }, + ), + ) + assert {item['field'] for item in changed['changes']} == {'nested'} + assert all( + value not in str(changed) + for value in ('stored-secret', 'nested-secret', 'replacement-secret', 'changed-secret') + ) + assert 'new' in str(changed) with pytest.raises(ValueError, match='no existing value'): await ops.update_config(ap, CONTEXT, ops.ConfigInput(author='a', plugin_name='b', config={'password': '***'})) diff --git a/tests/unit_tests/api/test_application_workspace_streams.py b/tests/unit_tests/api/test_application_workspace_streams.py index 782e056a7..8f68ebb0c 100644 --- a/tests/unit_tests/api/test_application_workspace_streams.py +++ b/tests/unit_tests/api/test_application_workspace_streams.py @@ -13,9 +13,7 @@ from langbot_plugin.api.entities.builtin.provider.message import MessageChunk from langbot.pkg.api.application.service_operations import TurnInput, _assistant_service from langbot.pkg.api.application.workspace_streams import AgentDebugInput, agent_debug, assistant_turn from langbot.pkg.api.http.service.assistant import AssistantError -from tests.unit_tests.api.test_management_assistant import context - -pytest_plugins = ['tests.unit_tests.api.test_management_assistant'] +from tests.unit_tests.api.test_management_assistant import assistant, context # noqa: F401 def scoped_app(debug): @@ -90,7 +88,7 @@ async def test_debug_stream_applies_backpressure_and_close_cancels_producer(): @pytest.mark.asyncio -async def test_assistant_stream_disconnect_preserves_run_and_bounded_queue(assistant): +async def test_assistant_stream_disconnect_preserves_run_and_bounded_queue(assistant): # noqa: F811 _, ap, provider = assistant service = _assistant_service(ap) release = asyncio.Event() diff --git a/tests/unit_tests/api/test_bot_controller_secrets.py b/tests/unit_tests/api/test_bot_controller_secrets.py index 971129315..6a84dc944 100644 --- a/tests/unit_tests/api/test_bot_controller_secrets.py +++ b/tests/unit_tests/api/test_bot_controller_secrets.py @@ -39,7 +39,7 @@ async def create_client(*, role: str): bot['adapter_config'] = SECRET_CONFIG return [bot] - async def get_runtime_bot_info(_context, _bot_uuid, *, include_secret=False): + async def get_runtime_bot_info(_context, bot_uuid, *, include_secret=False): bot = {'uuid': 'bot-test', 'name': 'Test Bot'} if include_secret: bot['adapter_config'] = SECRET_CONFIG @@ -66,6 +66,12 @@ async def create_client(*, role: str): workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)), bot_service=bot_service, ) + from tests.utils.application_api import bind_application_api + + bind_application_api( + application, + SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1), + ) router = BotsRouterGroup(application, quart_app) await router.initialize() return quart_app.test_client(), bot_service diff --git a/tests/unit_tests/api/test_bots_controller_errors.py b/tests/unit_tests/api/test_bots_controller_errors.py index 1d8730c61..c9864d58c 100644 --- a/tests/unit_tests/api/test_bots_controller_errors.py +++ b/tests/unit_tests/api/test_bots_controller_errors.py @@ -45,6 +45,11 @@ async def _create_test_client(bot_service: SimpleNamespace): apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None)), workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)), ) + from tests.utils.application_api import bind_application_api + + bind_application_api( + ap, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) + ) BotsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.platform.bots').BotsRouterGroup group = BotsRouterGroup(ap, app) await group.initialize() @@ -100,6 +105,24 @@ async def test_invalid_request_body_is_actionable(): service.update_bot.assert_not_awaited() +@pytest.mark.parametrize('body', [{}, {'event_type': None}, {'event_type': ''}]) +async def test_dry_run_invalid_event_remains_a_successful_diagnostic(body): + from langbot.pkg.api.http.service.bot import BotService + + client = await _create_test_client(BotService(SimpleNamespace())) + response = await client.post( + '/api/v1/platform/bots/bot-1/event-routes/dry-run', + json=body, + headers={'Authorization': 'Bearer token'}, + ) + + assert response.status_code == 200 + result = (await response.get_json())['data'] + assert result['matched'] is False + assert result['failure_code'] == 'invalid_event' + assert result['reason'] == 'event_type is required' + + async def test_runtime_error_redacts_persisted_secrets_on_partial_update(): from langbot.pkg.api.http.service.bot import BotService from langbot.pkg.api.http.service.bot_errors import BotApplyError diff --git a/tests/unit_tests/api/test_execution_application_api.py b/tests/unit_tests/api/test_execution_application_api.py index b2af9e1a8..cd9de80ad 100644 --- a/tests/unit_tests/api/test_execution_application_api.py +++ b/tests/unit_tests/api/test_execution_application_api.py @@ -3,17 +3,20 @@ from __future__ import annotations import asyncio import contextlib import contextvars +import json from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest from langbot_plugin.entities.io.context import ActionContext, InstallationBinding from langbot_plugin.entities.io.resp import ActionResponse +from langbot_plugin.entities.io.req import ActionRequest from langbot.pkg.api.application.contracts import ApplicationAPIException from langbot.pkg.api.application.execution import ExecutionAPI, ExecutionAPIContext from langbot.pkg.plugin.handler import RuntimeConnectionHandler from langbot.pkg.agent.runner.session_registry import get_session_registry +from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection @pytest.fixture @@ -73,8 +76,11 @@ def stub_run(monkeypatch, session): def install_action(host, name, action): # The production constructor applies this wrapper after registration. - host.handler.actions[name] = action + wire_actions = host.handler.actions + host.handler.actions = {name: action} host.handler._secure_plugin_actions() + host.handler._execution_actions[name] = host.handler.actions[name] + host.handler.actions = wire_actions @pytest.mark.asyncio @@ -522,3 +528,135 @@ async def test_platform_call_requires_bot_identity_outside_run(host): 'execution.call_platform_api', ExecutionAPIContext(host.binding), {'action': 'send_message'} ) assert exc.value.error.code == 'invalid_input' + + +@pytest.mark.asyncio +async def test_runtime_wire_calls_execution_api_and_preserves_response(host): + connection = RecordingConnection() + host.handler.conn = connection + host.handler.execution_api.invoke = AsyncMock(wraps=host.handler.execution_api.invoke) + request = ActionRequest.make_request(17, 'get_langbot_version', {}, host.binding) + + await host.handler._handle_action(request.model_dump()) + + response = json.loads(connection.sent[-1]) + assert response['seq_id'] == 17 + assert response['code'] == 0 + assert response['data']['version'] + host.handler.execution_api.invoke.assert_awaited_once_with( + 'execution.get_langbot_version', ExecutionAPIContext(host.binding), {} + ) + assert host.handler.current_action_context is None + assert host.tenant.get() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('denial', ['workspace', 'plugin', 'expired', 'caller', 'stale']) +async def test_runtime_wire_denies_untrusted_installation_and_run_before_model_access(host, monkeypatch, denial): + session = run_session(host) + if denial == 'workspace': + session['authorization']['workspace_id'] = 'workspace-b' + if denial == 'plugin': + session['plugin_identity'] = 'other/plugin' + stub_run(monkeypatch, None if denial == 'expired' else session) + host.ap.model_mgr = SimpleNamespace(get_model_by_uuid=AsyncMock()) + connection = RecordingConnection() + host.handler.conn = connection + binding = host.binding.model_copy(update={'runtime_revision': 2}) if denial == 'stale' else host.binding + request = ActionRequest.make_request( + 18, + 'invoke_llm', + { + 'run_id': 'run-a', + 'llm_model_uuid': 'model', + 'messages': [], + **({'caller_plugin_identity': 'other/plugin'} if denial == 'caller' else {}), + }, + binding, + ) + + await host.handler._handle_action(request.model_dump()) + + response = json.loads(connection.sent[-1]) + assert response['seq_id'] == 18 + assert response['code'] != 0 + assert response['data'] == {} + host.ap.model_mgr.get_model_by_uuid.assert_not_awaited() + assert host.handler.current_action_context is None + assert host.tenant.get() is None + + +@pytest.mark.asyncio +async def test_runtime_wire_retains_structured_box_errors(host, monkeypatch): + from langbot.pkg.box.export_errors import export_error + + stub_run(monkeypatch, run_session(host)) + error = export_error('file_bytes', limit=100, actual=101, path='report.txt') + expected = {'error': error.error.model_dump(mode='json')} + install_action(host, 'export_box_files', AsyncMock(return_value=ActionResponse.error(str(error), expected))) + connection = RecordingConnection() + host.handler.conn = connection + request = ActionRequest.make_request( + 19, 'export_box_files', {'run_id': 'run-a', 'paths': ['report.txt']}, host.binding + ) + + await host.handler._handle_action(request.model_dump()) + + response = json.loads(connection.sent[-1]) + assert response['code'] == 1 + assert response['message'] == str(error) + assert response['data'] == expected + + +@pytest.mark.asyncio +async def test_runtime_stream_close_cancels_producer_without_leaking_context(host): + closed = asyncio.Event() + + async def provider(data): + try: + assert host.handler.current_action_context == host.binding + assert host.tenant.get() == 'workspace-a' + yield ActionResponse.success({'chunk': {'text': 'first'}}) + await asyncio.Event().wait() + finally: + closed.set() + + install_action(host, 'invoke_llm_stream', provider) + token = host.handler._current_action_context.set(host.binding) + try: + async with contextlib.aclosing( + host.handler.actions['invoke_llm_stream']({'llm_model_uuid': 'model', 'messages': []}) + ) as stream: + response = await anext(stream) + assert response.code == 0 + assert response.data == {'chunk': {'text': 'first'}} + assert host.tenant.get() is None + assert closed.is_set() + assert host.handler.current_action_context == host.binding + finally: + host.handler._current_action_context.reset(token) + + +@pytest.mark.asyncio +async def test_runtime_adapter_keeps_sdk_model_deadline_and_legacy_tool_inputs_compatible(host): + model = AsyncMock(return_value=ActionResponse.success({'message': {'role': 'assistant', 'content': 'ok'}})) + tool = AsyncMock(return_value=ActionResponse.success({'tool_response': {'ok': True}})) + install_action(host, 'invoke_llm', model) + install_action(host, 'call_tool', tool) + token = host.handler._current_action_context.set(host.binding) + try: + response = await host.handler.actions['invoke_llm']( + {'llm_model_uuid': 'model', 'messages': [], 'timeout': 120.0} + ) + assert response.code == 0 + assert response.data['message']['content'] == 'ok' + assert 'timeout' not in model.call_args.args[0] + response = await host.handler.actions['call_tool']( + {'tool_name': 'tool', 'tool_parameters': {'x': 1}, 'session': {}, 'query_id': 7} + ) + assert response.code == 0 + assert response.data == {'tool_response': {'ok': True}} + assert tool.call_args.args[0]['query_id'] == 7 + assert tool.call_args.args[0]['tool_parameters'] == {'x': 1} + finally: + host.handler._current_action_context.reset(token) diff --git a/tests/unit_tests/api/test_extensions_runtime_fence.py b/tests/unit_tests/api/test_extensions_runtime_fence.py index 02bff861f..e265a81d6 100644 --- a/tests/unit_tests/api/test_extensions_runtime_fence.py +++ b/tests/unit_tests/api/test_extensions_runtime_fence.py @@ -39,6 +39,9 @@ async def test_extensions_route_hides_runtime_bound_to_another_workspace(): skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), ) quart_app = quart.Quart(__name__) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=2)) router = ExtensionsRouterGroup(ap, quart_app) await router.initialize() @@ -81,6 +84,9 @@ async def test_extensions_route_redacts_plugin_secrets_without_mutating_runtime_ skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), ) quart_app = quart.Quart(__name__) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=2)) router = ExtensionsRouterGroup(ap, quart_app) await router.initialize() @@ -131,6 +137,9 @@ async def test_extensions_parallel_reads_open_explicit_child_task_scopes(): ap.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(side_effect=list_mcp_servers)) ap.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[])) quart_app = quart.Quart(__name__) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=2)) router = ExtensionsRouterGroup(ap, quart_app) await router.initialize() diff --git a/tests/unit_tests/api/test_file_upload_scoping.py b/tests/unit_tests/api/test_file_upload_scoping.py index 7d13f92dc..b289282b2 100644 --- a/tests/unit_tests/api/test_file_upload_scoping.py +++ b/tests/unit_tests/api/test_file_upload_scoping.py @@ -36,6 +36,9 @@ async def test_document_upload_uses_dedicated_scoped_owner_type(): workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)), storage_mgr=storage_mgr, ) + from tests.utils.application_api import bind_application_api + + bind_application_api(application, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='00000000-0000-0000-0000-00000000000a', placement_generation=3)) router = FilesRouterGroup(application, quart_app) await router.initialize() client = quart_app.test_client() diff --git a/tests/unit_tests/api/test_management_assistant.py b/tests/unit_tests/api/test_management_assistant.py index 8e6fc78b7..4b65d1dec 100644 --- a/tests/unit_tests/api/test_management_assistant.py +++ b/tests/unit_tests/api/test_management_assistant.py @@ -15,6 +15,7 @@ from langbot.pkg.api.http.context import RequestContext, PrincipalContext, Princ from langbot.pkg.api.http.service.assistant import AssistantError, AssistantService from langbot.pkg.api.http.service.assistant_tools import execute_tool, validate_call from langbot.pkg.entity.persistence.assistant import AssistantConversation +from tests.utils.application_api import bind_application_api def context(account='alice', workspace='workspace-a', manage=True): @@ -78,7 +79,9 @@ async def assistant(tmp_path): model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), pipeline_service=SimpleNamespace(create_pipeline=AsyncMock(return_value='created-pipeline')), ) - yield AssistantService(ap), ap, provider + bind_application_api(ap, context()) + service = ap._application_assistant_service = AssistantService(ap) + yield service, ap, provider await engine.dispose() @@ -101,7 +104,7 @@ async def test_confirmation_is_exact_once_and_private(assistant): ) assert sum(isinstance(result, AssistantError) for result in results) == 1 ap.pipeline_service.create_pipeline.assert_awaited_once() - assert ap.pipeline_service.create_pipeline.call_args.args[1]['name'] == 'Demo' + assert ap.pipeline_service.create_pipeline.call_args.kwargs['pipeline_data']['name'] == 'Demo' saved = await service.get(ctx, conversation['uuid']) assert saved['status'] == 'ready' assert saved['messages'][1]['provider_specific_fields']['thought_signature'] == 'preserved' @@ -172,17 +175,17 @@ def test_rejected_malformed_tool_call_remains_readable(): @pytest.mark.asyncio async def test_resource_readers_match_application_services(): - from langbot.pkg.core.app import Application from langbot.pkg.api.http.service.model import LLMModelsService, EmbeddingModelsService from langbot.pkg.api.http.service.pipeline import PipelineService from langbot.pkg.api.http.service.knowledge import KnowledgeService - ap = create_autospec(Application, instance=True, spec_set=True) + ap = SimpleNamespace() ap.llm_model_service = create_autospec(LLMModelsService, instance=True) ap.embedding_models_service = create_autospec(EmbeddingModelsService, instance=True) ap.pipeline_service = create_autospec(PipelineService, instance=True) ap.knowledge_service = create_autospec(KnowledgeService, instance=True) ctx = context() + bind_application_api(ap, ctx) for kind, reader in ( ('models', ap.llm_model_service.get_llm_models), ('embedding_models', ap.embedding_models_service.get_embedding_models), @@ -197,7 +200,9 @@ async def test_resource_readers_match_application_services(): 'total': 1, 'items': [{'name': kind}], } - reader.assert_awaited_once_with(ctx) + assert reader.await_count == 1 + assert reader.call_args.args == (ctx,) + assert not reader.call_args.kwargs.get('include_secret', False) @pytest.mark.asyncio @@ -358,7 +363,8 @@ async def test_http_stream_emits_before_provider_completion_and_restores_tenant( import json import quart from langbot_plugin.api.entities.builtin.provider.message import MessageChunk - from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response, TurnInput + from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response + from langbot.pkg.api.application.transport import stream_application service, ap, provider = assistant released = asyncio.Event() @@ -373,7 +379,7 @@ async def test_http_stream_emits_before_provider_completion_and_restores_tenant( scoped.pop() async def stream(**kwargs): - assert scoped == ['workspace-a'] + assert scoped == ['workspace-a', 'workspace-a'] yield MessageChunk(role='assistant', content='First') await released.wait() yield MessageChunk(role='assistant', content=' second', is_final=True) @@ -386,7 +392,14 @@ async def test_http_stream_emits_before_provider_completion_and_restores_tenant( @app.post('/stream') async def route(): - return assistant_stream_response(service, ctx, cid, TurnInput(revision=0, text='Hi')) + return await assistant_stream_response( + stream_application( + ap, + 'assistant.stream', + ctx, + {'conversation_id': cid, 'revision': 0, 'text': 'Hi'}, + ) + ) frames = [] async with app.test_client().request('/stream', method='POST') as connection: @@ -408,7 +421,8 @@ async def test_background_run_survives_http_disconnect_and_finishes(assistant): import json import quart from langbot_plugin.api.entities.builtin.provider.message import MessageChunk - from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response, TurnInput + from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response + from langbot.pkg.api.application.transport import stream_application service, ap, provider = assistant released = asyncio.Event() @@ -425,7 +439,14 @@ async def test_background_run_survives_http_disconnect_and_finishes(assistant): @app.post('/stream') async def route(): - return assistant_stream_response(service, ctx, cid, TurnInput(revision=0, text='Hi')) + return await assistant_stream_response( + stream_application( + ap, + 'assistant.stream', + ctx, + {'conversation_id': cid, 'revision': 0, 'text': 'Hi'}, + ) + ) async with app.test_client().request('/stream', method='POST') as connection: while True: @@ -481,3 +502,84 @@ async def test_stop_is_private_revision_scoped_and_does_not_stop_other_chat(assi released.set() await asyncio.wait_for(tasks[1], 5) assert (await service.get(ctx, ids[1]))['status'] == 'ready' + + +@pytest.mark.asyncio +async def test_http_assistant_uses_application_operations_and_preserves_startup_errors(assistant): + from quart import Quart + from langbot.pkg.api.http.controller.groups.assistant import AssistantRouterGroup + + service, ap, provider = assistant + app = Quart(__name__) + router = AssistantRouterGroup(ap, app) + router._authenticate_support_admin = AsyncMock(return_value=None) + router._authenticate_account = AsyncMock(return_value=(SimpleNamespace(uuid='alice'), 'alice@example.invalid')) + router._resolve_account_context = AsyncMock(return_value=context()) + await router.initialize() + client = app.test_client() + base = '/api/v1/assistant/conversations' + headers = {'Authorization': 'Bearer synthetic-test'} + + assert (await client.post(base)).status_code == 401 + created = await client.post(base, headers=headers) + assert created.status_code == 200 + conversation = (await created.get_json())['data'] + cid = conversation['uuid'] + listed = (await (await client.get(base, headers=headers)).get_json())['data']['conversations'] + assert [row['uuid'] for row in listed] == [cid] + turn = await client.post(f'{base}/{cid}/turn', headers=headers, json={'revision': 0, 'text': 'Create'}) + assert turn.status_code == 200 + assert (await turn.get_json())['data']['status'] == 'approval' + approved = await client.post(f'{base}/{cid}/turn', headers=headers, json={'revision': 1, 'approved': True}) + assert (await approved.get_json())['data']['status'] == 'ready' + ap.pipeline_service.create_pipeline.assert_awaited_once() + stale = await client.post(f'{base}/{cid}/turn', headers=headers, json={'revision': 1, 'approved': True}) + assert stale.status_code == 409 + assert (await stale.get_json())['code'] == 'stale_turn' + + router._resolve_account_context.return_value = context(account='bob') + missing = await client.post(f'{base}/{cid}/turn/stream', headers=headers, json={'revision': 2, 'text': 'Hi'}) + assert missing.status_code == 404 + assert (await missing.get_json())['code'] == 'conversation_not_found' + router._resolve_account_context.return_value = context() + service._runs.update({f'occupied-{index}': {} for index in range(4)}) + try: + busy = await client.post(f'{base}/{cid}/turn/stream', headers=headers, json={'revision': 2, 'text': 'Hi'}) + assert busy.status_code == 429 + assert (await busy.get_json())['code'] == 'busy' + finally: + service._runs.clear() + + +@pytest.mark.asyncio +async def test_background_assistant_tools_keep_their_audit_after_http_dispatch(assistant): + from langbot.pkg.api.application.transport import transport_records_audit + from langbot.pkg.api.http.controller.groups.assistant import TurnInput + from langbot.pkg.operation_trace.service import OPERATION_LEVEL_READ + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + + service, ap, provider = assistant + ctx = context() + cid = (await service.create(ctx))['uuid'] + await service.turn(ctx, cid, 0, text='Create') + ap.workspace_settings_service = SimpleNamespace( + effective_level=AsyncMock(return_value=OPERATION_LEVEL_READ), + record=AsyncMock(), + ) + + async def completed(**kwargs): + yield MessageChunk(role='assistant', content='Done', is_final=True) + + provider.invoke_llm_stream = completed + token = transport_records_audit.set(True) + try: + service.start_run(ctx, cid, TurnInput(revision=1, approved=True)) + await service._runs[cid]['task'] + assert transport_records_audit.get() is True + finally: + transport_records_audit.reset(token) + ap.workspace_settings_service.record.assert_awaited_once() + record = ap.workspace_settings_service.record.call_args.kwargs + assert record['route'] == 'pipeline.create' + assert record['outcome'] == 'ok' + assert (await service.get(ctx, cid))['status'] == 'ready' diff --git a/tests/unit_tests/api/test_mcp_controller.py b/tests/unit_tests/api/test_mcp_controller.py index d5e9f6da1..68ccb3708 100644 --- a/tests/unit_tests/api/test_mcp_controller.py +++ b/tests/unit_tests/api/test_mcp_controller.py @@ -49,6 +49,11 @@ async def _create_test_client(mcp_service: SimpleNamespace, *, role: str = 'owne user_service=user_service, workspace_collaboration_service=workspace_collaboration_service, ) + from tests.utils.application_api import bind_application_api + + bind_application_api( + ap, SimpleNamespace(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1) + ) MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup group = MCPRouterGroup(ap, app) await group.initialize() @@ -98,7 +103,8 @@ async def test_mcp_server_route_accepts_encoded_slash_name(): assert response.status_code == 200 mcp_service.get_mcp_server_by_name.assert_awaited_once() - context, server_name = mcp_service.get_mcp_server_by_name.await_args.args + (context,) = mcp_service.get_mcp_server_by_name.await_args.args + server_name = mcp_service.get_mcp_server_by_name.await_args.kwargs['server_name'] assert context.workspace_uuid == 'workspace-a' assert server_name == 'pab1it0/prometheus' payload = await response.get_json() @@ -125,7 +131,8 @@ async def test_mcp_resource_route_accepts_encoded_slash_name(): assert response.status_code == 200 mcp_service.get_mcp_server_by_name.assert_not_awaited() mcp_service.get_mcp_server_resources.assert_awaited_once() - context, server_name = mcp_service.get_mcp_server_resources.await_args.args + (context,) = mcp_service.get_mcp_server_resources.await_args.args + server_name = mcp_service.get_mcp_server_resources.await_args.kwargs['server_name'] assert context.workspace_uuid == 'workspace-a' assert server_name == 'pab1it0/prometheus' payload = await response.get_json() diff --git a/tests/unit_tests/api/test_mcp_server.py b/tests/unit_tests/api/test_mcp_server.py index ab844ba67..15a1945c5 100644 --- a/tests/unit_tests/api/test_mcp_server.py +++ b/tests/unit_tests/api/test_mcp_server.py @@ -8,6 +8,9 @@ from mcp.server.fastmcp.exceptions import ToolError from langbot.pkg.api.mcp import server as mcp_server from langbot.pkg.api.mcp.server import LangBotMCPServer +from langbot.pkg.api.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext +from langbot.pkg.api.authz import permissions_for_role +from tests.utils.application_api import bind_application_api def _make_app() -> SimpleNamespace: @@ -91,7 +94,15 @@ async def test_monitoring_sessions_accept_only_conversation_filters(monkeypatch, normalize_page_window=lambda limit, offset: (limit, offset), get_sessions=AsyncMock(return_value=([], 0)), ) - context = SimpleNamespace(workspace_uuid='workspace-1') + context = RequestContext( + 'instance', + 1, + 'request', + 'api_key', + PrincipalContext(PrincipalType.API_KEY, api_key_uuid='key'), + WorkspaceContext('workspace-1', None, 'developer', permissions_for_role('developer')), + ) + bind_application_api(app, context) monkeypatch.setattr(mcp_server, 'get_request_context', lambda: context) monkeypatch.setattr(mcp_server, '_authorized', lambda permission: context) monkeypatch.setattr(mcp_server, 'get_workspace_language', AsyncMock(return_value='en_US')) @@ -104,4 +115,47 @@ async def test_monitoring_sessions_accept_only_conversation_filters(monkeypatch, app.monitoring_service.get_sessions.assert_not_awaited() else: await server.mcp.call_tool('get_monitoring_records', arguments) - app.monitoring_service.get_sessions.assert_awaited_once_with(context, limit=100, offset=0, **filters) + app.monitoring_service.get_sessions.assert_awaited_once() + args, kwargs = app.monitoring_service.get_sessions.call_args + assert args == (context,) + assert kwargs['limit'] == 100 and kwargs['offset'] == 0 + assert all(kwargs[key] == value for key, value in filters.items()) + + +@pytest.mark.asyncio +async def test_mcp_application_binding_permissions_and_default_pipeline(monkeypatch): + import json + from dataclasses import replace + + app = _make_app() + context = RequestContext( + 'instance', + 1, + 'request', + 'api_key', + PrincipalContext(PrincipalType.API_KEY, api_key_uuid='key'), + WorkspaceContext('workspace-1', None, 'developer', permissions_for_role('developer')), + ) + bind_application_api(app, context) + monkeypatch.setattr(mcp_server, 'get_request_context', lambda: context) + server = LangBotMCPServer(app) + result = await server.mcp.call_tool('create_pipeline', {'pipeline_data': {'name': 'Default', 'is_default': True}}) + assert json.loads(result[0][0].text) == {'uuid': 'pipeline-1'} + assert app.pipeline_service.create_pipeline.call_args.kwargs['default'] is True + assert app.pipeline_service.create_pipeline.call_args.kwargs['pipeline_data'] == { + 'name': 'Default', + 'is_default': True, + } + app.pipeline_service.create_pipeline.reset_mock() + + context = replace( + context, workspace=replace(context.workspace, role='viewer', permissions=permissions_for_role('viewer')) + ) + with pytest.raises(ToolError, match='resource.manage'): + await server.mcp.call_tool('create_pipeline', {'pipeline_data': {'name': 'Denied'}}) + app.pipeline_service.create_pipeline.assert_not_awaited() + + app.workspace_service.get_execution_binding.return_value.instance_uuid = 'other-instance' + with pytest.raises(ToolError, match='different instance'): + await server.mcp.call_tool('list_pipelines', {}) + app.pipeline_service.get_pipelines.assert_not_awaited() diff --git a/tests/unit_tests/api/test_pipeline_migration_routes.py b/tests/unit_tests/api/test_pipeline_migration_routes.py index 6e0ff4b0c..606811d7e 100644 --- a/tests/unit_tests/api/test_pipeline_migration_routes.py +++ b/tests/unit_tests/api/test_pipeline_migration_routes.py @@ -30,8 +30,11 @@ async def test_manual_routes_user_token_permissions_and_strict_request(): WorkspaceContext('workspace', 'membership', 'viewer', permissions_for_role('viewer')), ) router._resolve_account_context = AsyncMock(return_value=viewer) + from tests.utils.application_api import bind_application_api + + bind_application_api(router.ap, viewer) await router.initialize() - router.service = NS( + router.ap._application_pipeline_migration_service = NS( preview=AsyncMock(return_value={'items': [], 'total': 0}), execute=AsyncMock(return_value={'task_id': 1}) ) client = app.test_client() @@ -41,7 +44,7 @@ async def test_manual_routes_user_token_permissions_and_strict_request(): headers = {'Authorization': 'Bearer synthetic'} assert (await client.get(base + '/preview', headers=headers)).status_code == 200 assert (await client.post(base + '/execute', headers=headers, json={})).status_code == 403 - router.service.execute.assert_not_awaited() + router.ap._application_pipeline_migration_service.execute.assert_not_awaited() manager = RequestContext( 'instance', 1, @@ -51,10 +54,10 @@ async def test_manual_routes_user_token_permissions_and_strict_request(): WorkspaceContext('workspace', 'membership', 'developer', permissions_for_role('developer')), ) router._resolve_account_context.return_value = manager - router.service.execute.side_effect = MigrationError('confirmation_required', 400) + router.ap._application_pipeline_migration_service.execute.side_effect = MigrationError('confirmation_required', 400) response = await client.post(base + '/execute', headers=headers, json={'confirmed': 'true'}) assert response.status_code == 400 assert 'confirmation_required' in await response.get_data(as_text=True) - router.service.execute.side_effect = None + router.ap._application_pipeline_migration_service.execute.side_effect = None response = await client.post(base + '/execute', headers=headers, json={'confirmed': True, 'items': []}) assert (await response.get_json())['data']['task_id'] == 1 diff --git a/tests/unit_tests/api/test_pipelines_controller.py b/tests/unit_tests/api/test_pipelines_controller.py index f8bd140bd..a29875097 100644 --- a/tests/unit_tests/api/test_pipelines_controller.py +++ b/tests/unit_tests/api/test_pipelines_controller.py @@ -50,6 +50,15 @@ async def _create_test_client(pipeline_service: SimpleNamespace, **extra_ap): ), **extra_ap, ) + from tests.utils.application_api import bind_application_api + + bind_application_api( + ap, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) + ) + from langbot.pkg.api.http.service.pipeline import PipelineService + + pipeline_service.ap = ap + pipeline_service.get_pipeline_extensions = PipelineService.get_pipeline_extensions.__get__(pipeline_service) router_class = import_module('langbot.pkg.api.http.controller.groups.pipelines.pipelines').PipelinesRouterGroup group = router_class(ap, app) await group.initialize() @@ -191,7 +200,7 @@ async def test_pipeline_extensions_get_normalizes_malformed_enable_all_flags(inv plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])), mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])), skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), - logger=SimpleNamespace(warning=AsyncMock()), + logger=SimpleNamespace(warning=Mock()), ) response = await client.get( @@ -210,16 +219,14 @@ async def test_pipeline_extensions_get_normalizes_malformed_enable_all_flags(inv @pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) async def test_pipeline_extensions_get_malformed_root_is_fail_closed(invalid_preferences): pipeline_service = SimpleNamespace( - get_pipeline=AsyncMock( - return_value={'extensions_preferences': invalid_preferences} - ), + get_pipeline=AsyncMock(return_value={'extensions_preferences': invalid_preferences}), ) client = await _create_test_client( pipeline_service, plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])), mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])), skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), - logger=SimpleNamespace(warning=AsyncMock()), + logger=SimpleNamespace(warning=Mock()), ) response = await client.get( diff --git a/tests/unit_tests/api/test_plugin_runtime_route_fence.py b/tests/unit_tests/api/test_plugin_runtime_route_fence.py index 509684fe8..36372057b 100644 --- a/tests/unit_tests/api/test_plugin_runtime_route_fence.py +++ b/tests/unit_tests/api/test_plugin_runtime_route_fence.py @@ -8,9 +8,15 @@ import pytest import quart from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.api.application.plugin_operations import _run_fenced from langbot.pkg.workspace.errors import WorkspaceNotFoundError +@asynccontextmanager +async def _empty_scope(workspace_uuid): + yield + + CONTEXT = ExecutionContext( instance_uuid='instance-a', workspace_uuid='workspace-a', @@ -115,10 +121,10 @@ async def test_background_plugin_operation_refences_captured_generation(plugin_r require_workspace_context=AsyncMock(side_effect=WorkspaceNotFoundError('Plugin resource not found')), ) router = object.__new__(plugin_router_cls) - router.ap = SimpleNamespace(plugin_connector=connector) + router.ap = SimpleNamespace(plugin_connector=connector, persistence_mgr=SimpleNamespace(tenant_scope=_empty_scope)) with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'): - await router._run_fenced_plugin_operation(CONTEXT, operation) + await _run_fenced(router.ap, CONTEXT, operation) operation.assert_not_awaited() @@ -158,7 +164,7 @@ async def test_background_plugin_operation_revalidates_and_runs_inside_tenant_uo ), ) - result = await router._run_fenced_plugin_operation(CONTEXT, operation) + result = await _run_fenced(router.ap, CONTEXT, operation) assert result == 'done' assert scopes == [CONTEXT.workspace_uuid] diff --git a/tests/unit_tests/api/test_provider_controller_secrets.py b/tests/unit_tests/api/test_provider_controller_secrets.py index 73d9fe990..94937c8c1 100644 --- a/tests/unit_tests/api/test_provider_controller_secrets.py +++ b/tests/unit_tests/api/test_provider_controller_secrets.py @@ -56,7 +56,7 @@ async def _create_client(role: str): async def get_providers(_context, *, include_secret=False): return [_project(RAW_PROVIDER, include_secret)] - async def get_provider(_context, _uuid, *, include_secret=False): + async def get_provider(_context, provider_uuid, *, include_secret=False): return _project(RAW_PROVIDER, include_secret) application.provider_service = SimpleNamespace( @@ -69,7 +69,7 @@ async def _create_client(role: str): async def get_models(_context, *, include_secret=False): return [_project(RAW_MODEL, include_secret)] - async def get_model(_context, _uuid, *, include_secret=False): + async def get_model(_context, model_uuid, *, include_secret=False): return _project(RAW_MODEL, include_secret) return SimpleNamespace( @@ -83,6 +83,12 @@ async def _create_client(role: str): application.embedding_models_service = model_service('get_embedding_models', 'get_embedding_model') application.rerank_models_service = model_service('get_rerank_models', 'get_rerank_model') + from tests.utils.application_api import bind_application_api + + bind_application_api( + application, + SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1), + ) quart_app = quart.Quart(__name__) for router_type in ( ModelProvidersRouterGroup, diff --git a/tests/unit_tests/api/test_resource_secret_permissions.py b/tests/unit_tests/api/test_resource_secret_permissions.py index 9e4e31d98..b3635f41f 100644 --- a/tests/unit_tests/api/test_resource_secret_permissions.py +++ b/tests/unit_tests/api/test_resource_secret_permissions.py @@ -62,11 +62,11 @@ async def _create_client(role: str): resolve_account_workspace=AsyncMock(return_value=_access(role)) ) - async def get_pipelines(_context, *_args, include_secret=False): + async def get_pipelines(_context, sort_by, sort_order, *, include_secret=False): value = copy.deepcopy(RAW_PIPELINE) return [value] if include_secret else [redact_secrets(value)] - async def get_pipeline(_context, _uuid, *, include_secret=False): + async def get_pipeline(_context, pipeline_uuid, *, include_secret=False): value = copy.deepcopy(RAW_PIPELINE) return value if include_secret else redact_secrets(value) @@ -74,13 +74,13 @@ async def _create_client(role: str): get_pipelines=AsyncMock(side_effect=get_pipelines), get_pipeline=AsyncMock(side_effect=get_pipeline), ) - application.plugin_connector = SimpleNamespace(list_plugins=AsyncMock(return_value=[])) + application.plugin_connector = SimpleNamespace(is_enable_plugin=False, list_plugins=AsyncMock(return_value=[])) application.mcp_service = SimpleNamespace( get_mcp_servers=AsyncMock(return_value=[redact_secrets(copy.deepcopy(RAW_MCP_SERVER))]) ) application.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[])) - async def get_models_by_provider(_context, _provider_uuid, *, include_secret=False): + async def get_models_by_provider(_context, provider_uuid, *, include_secret=False): value = copy.deepcopy(RAW_MODEL) return [value] if include_secret else [redact_secrets(value)] @@ -111,6 +111,18 @@ async def _create_client(role: str): application.webhook_service = SimpleNamespace(get_webhooks=AsyncMock(side_effect=get_webhooks)) + from tests.utils.application_api import bind_application_api + + bind_application_api( + application, + SimpleNamespace(instance_uuid='instance-test', workspace_uuid=WORKSPACE_UUID, placement_generation=1), + ) + from langbot.pkg.api.http.service.pipeline import PipelineService + + application.pipeline_service.ap = application + application.pipeline_service.get_pipeline_extensions = PipelineService.get_pipeline_extensions.__get__( + application.pipeline_service + ) quart_app = quart.Quart(__name__) for router_type in ( PipelinesRouterGroup, diff --git a/tests/unit_tests/api/test_slack_setup.py b/tests/unit_tests/api/test_slack_setup.py index 2a25effd6..98747e994 100644 --- a/tests/unit_tests/api/test_slack_setup.py +++ b/tests/unit_tests/api/test_slack_setup.py @@ -151,3 +151,62 @@ async def test_setup_webhook_only_accepts_signed_verification(): def test_invalid_callback_url(url): with pytest.raises(SlackSetupError): callback_url(url, 'bot') + + +@pytest.mark.asyncio +async def test_slack_http_application_adapter_preserves_errors_credentials_and_cache_headers(): + from unittest.mock import Mock + from quart import Quart + from langbot.pkg.api.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext + from langbot.pkg.api.http.controller.groups.platform.adapters import AdaptersRouterGroup + from tests.utils.application_api import bind_application_api + + ctx = RequestContext( + 'instance', + 1, + 'request', + 'api_key', + PrincipalContext(PrincipalType.API_KEY, api_key_uuid='key'), + WorkspaceContext('workspace', None, 'developer', frozenset({'resource.manage'})), + ) + setup = SimpleNamespace( + start=AsyncMock(return_value={'session_id': 'session'}), + status=Mock(return_value={'status': 'success', 'config': {'bot_token': 'synthetic-token'}}), + owned=Mock(), + discard=Mock(), + ) + ap = SimpleNamespace(slack_setup_service=setup, task_mgr=SimpleNamespace()) + bind_application_api(ap, ctx) + web = Quart(__name__) + router = AdaptersRouterGroup(ap, web) + router._authenticate_api_key = AsyncMock(return_value=ctx) + await router.initialize() + client = web.test_client() + headers = {'X-API-Key': 'synthetic-test'} + path = '/api/v1/platform/adapters/slack/setup' + + invalid = await client.post(path, headers=headers, json={}) + assert invalid.status_code == 400 + assert (await invalid.get_json())['msg'] == 'invalid_input' + setup.start.assert_not_awaited() + started = await client.post( + path, + headers=headers, + json={ + 'bot_uuid': 'bot', + 'access_token': 'access', + 'refresh_token': 'refresh', + }, + ) + assert (await started.get_json())['data'] == {'session_id': 'session'} + polled = await client.get(path + '/session', headers=headers) + assert polled.status_code == 200 + assert polled.headers['Cache-Control'] == 'no-store' + assert (await polled.get_json())['data'] == {'status': 'success', 'config': {'bot_token': 'synthetic-token'}} + cancelled = await client.delete(path + '/session', headers=headers) + assert (await cancelled.get_json())['data'] == {} + setup.discard.assert_called_once_with('session') + setup.status.side_effect = SlackSetupError('not owned') + missing = await client.get(path + '/foreign', headers=headers) + assert missing.status_code == 404 + assert (await missing.get_json())['msg'] == 'session_not_found' diff --git a/tests/unit_tests/api/test_stats_controller.py b/tests/unit_tests/api/test_stats_controller.py index 2fdfcf731..52b2f1f9c 100644 --- a/tests/unit_tests/api/test_stats_controller.py +++ b/tests/unit_tests/api/test_stats_controller.py @@ -71,6 +71,9 @@ async def create_client(*, role='viewer'): ), query_pool=SimpleNamespace(get_query_count=get_query_count), ) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-a', placement_generation=1)) router = StatsRouterGroup(ap, quart_app) await router.initialize() return quart_app.test_client(), collaboration_service diff --git a/tests/unit_tests/api/test_tools_controller.py b/tests/unit_tests/api/test_tools_controller.py index dfdb29a75..66e002da3 100644 --- a/tests/unit_tests/api/test_tools_controller.py +++ b/tests/unit_tests/api/test_tools_controller.py @@ -46,6 +46,9 @@ async def _create_test_client(tool_mgr: SimpleNamespace, pipeline_service: Simpl resolve_account_workspace=AsyncMock(return_value=access) ), ) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, SimpleNamespace(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1)) router_class = import_module('langbot.pkg.api.http.controller.groups.resources.tools').ToolsRouterGroup group = router_class(ap, app) await group.initialize() diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index 98dfeba04..25b9c3ece 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -283,7 +283,7 @@ class TestHandlerQueryVariables: ) assert response.code != 0 - assert response.message == 'Query variable key must be a non-empty string' + assert response.message == 'Input does not match ExecutionSetQueryVariableInput' assert mock_query.variables == {} @pytest.mark.asyncio diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 8fd068745..1764f8858 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -903,6 +903,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=900, @@ -939,6 +940,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=901, @@ -1017,6 +1019,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=905, @@ -1060,6 +1063,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=906, @@ -1116,6 +1120,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=None, @@ -1162,6 +1167,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=902, @@ -1226,6 +1232,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=904, @@ -1283,6 +1290,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=906, @@ -1329,6 +1337,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=903, @@ -1376,6 +1385,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=None, @@ -1476,6 +1486,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=None, @@ -1515,6 +1526,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=907, @@ -1557,6 +1569,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=None, @@ -1642,6 +1655,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=None, @@ -1681,6 +1695,7 @@ class TestAgentRunProxyActions: registry = get_session_registry() await registry.unregister(run_id) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=904, @@ -1780,6 +1795,7 @@ async def test_reply_stream_authorization_is_run_and_workspace_scoped(case): ] ) await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test/runner/default', query_id=None, @@ -1836,6 +1852,7 @@ async def test_public_platform_api_preserves_runner_authorization_and_mock(mode) registry = get_session_registry() run_id = 'public-platform-' + mode await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test-author/test-plugin/runner', query_id=None, @@ -1891,6 +1908,7 @@ async def test_embedding_api_rejects_model_outside_runner_grants(): registry = get_session_registry() run_id = 'embedding-ungranted' await registry.register( + workspace_id='workspace-a', run_id=run_id, runner_id='plugin:test-author/test-plugin/runner', query_id=None, @@ -1987,8 +2005,8 @@ async def test_knowledge_actions_enforce_run_grants_before_resource_access(knowl == { 'unselected': 'knowledge_base kb-unselected is not authorized for operation retrieve for this agent run', 'operation': 'knowledge_base kb-a is not authorized for operation retrieve for this agent run', - 'expired': 'Run session expired not found or expired', - 'plugin': 'Plugin identity mismatch: caller test-author/test-plugin is not authorized for run_id run-a', + 'expired': 'Run does not belong to this installation Workspace', + 'plugin': 'Run does not belong to this installation Workspace', }[denial] ) assert not response.data @@ -2014,9 +2032,9 @@ async def test_knowledge_actions_keep_foreign_runs_in_trusted_workspace(knowledg {'run_id': 'run-b', 'kb_id': 'kb-b', 'query_text': 'hello', 'workspace_uuid': 'workspace-b'} ) assert response.code != 0 - assert response.message == 'Knowledge base kb-b not found' + assert response.message == 'Run does not belong to this installation Workspace' assert not response.data - host.app.rag_mgr.get_knowledge_base_by_uuid.assert_awaited_once_with(TEST_EXECUTION_CONTEXT, 'kb-b') + host.app.rag_mgr.get_knowledge_base_by_uuid.assert_not_awaited() host.app.plugin_connector.call_rag_retrieve.assert_not_awaited() diff --git a/tests/unit_tests/plugin/test_handler_tenancy.py b/tests/unit_tests/plugin/test_handler_tenancy.py index 370ab4cb7..cb2e92db4 100644 --- a/tests/unit_tests/plugin/test_handler_tenancy.py +++ b/tests/unit_tests/plugin/test_handler_tenancy.py @@ -135,7 +135,10 @@ async def test_runtime_action_enters_trusted_workspace_scope(): ) assert response.code == 0 - assert scope_events == [('enter', 'workspace-a'), ('exit', 'workspace-a')] + assert scope_events[0] == ('enter', 'workspace-a') + assert scope_events[-1] == ('exit', 'workspace-a') + assert {workspace for _, workspace in scope_events} == {'workspace-a'} + assert sum(event == 'enter' for event, _ in scope_events) == sum(event == 'exit' for event, _ in scope_events) @pytest.mark.asyncio @@ -223,13 +226,14 @@ async def test_plugin_action_rejects_stale_workspace_generation(): runtime_handler, app, installation_context = make_handler() app.workspace_service.get_execution_binding.side_effect = ValueError('generation is fenced') - with pytest.raises(ValueError, match='generation is fenced'): - await invoke_with_context( - runtime_handler, - installation_context, - PluginToRuntimeAction.GET_LANGBOT_VERSION, - {}, - ) + response = await invoke_with_context( + runtime_handler, + installation_context, + PluginToRuntimeAction.GET_LANGBOT_VERSION, + {}, + ) + assert response.code != 0 + assert response.message == 'Installation binding is invalid or stale' app.workspace_service.get_execution_binding.assert_awaited_once_with( 'workspace-a', diff --git a/tests/unit_tests/plugin/test_runner_reasoning_override.py b/tests/unit_tests/plugin/test_runner_reasoning_override.py index d0bf068dc..acbfa456e 100644 --- a/tests/unit_tests/plugin/test_runner_reasoning_override.py +++ b/tests/unit_tests/plugin/test_runner_reasoning_override.py @@ -52,8 +52,11 @@ class RecordingProvider: @pytest.fixture async def host(monkeypatch): + from langbot.pkg.agent.runner import session_registry + registry = AgentRunSessionRegistry() monkeypatch.setattr(handler_module, 'get_session_registry', lambda: registry) + monkeypatch.setattr(session_registry, '_global_registry', registry) request = _requester('openai') monkeypatch.setattr(request, '_supports_reasoning', lambda _: True) monkeypatch.setattr(request, '_safe_model_info', lambda _: {}) @@ -217,8 +220,8 @@ async def test_model_runtime_workspace_mismatch_denies(host, action): async def test_host_reuses_core_ability_validation_before_provider_call(host, action): await host.register() host.models[PRIMARY].model_entity.abilities = [] - with pytest.raises(ValueError, match='reasoning ability'): - await call(host, action, reasoning_level='high') + responses = await call(host, action, reasoning_level='high') + assert all(response.code != 0 for response in responses) assert not host.provider.calls diff --git a/tests/unit_tests/provider/test_codex_errors.py b/tests/unit_tests/provider/test_codex_errors.py index bfd5f6596..10a385be0 100644 --- a/tests/unit_tests/provider/test_codex_errors.py +++ b/tests/unit_tests/provider/test_codex_errors.py @@ -12,6 +12,7 @@ from langbot.pkg.api.http.controller.groups.provider.models import ( RerankModelsRouterGroup, ) from langbot.pkg.api.http.authz import Permission +from langbot.pkg.api.context import RequestContext, WorkspaceContext, PrincipalContext, PrincipalType from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester from tests.unit_tests.provider.test_codex import requester, MODEL, stream @@ -78,12 +79,18 @@ async def client_for(error, model_kind='llm'): service = SimpleNamespace(**{method_name: AsyncMock(side_effect=error)}) ap = SimpleNamespace(logger=Mock(), **{service_name: service}) router = router_class(ap, app) - router._authenticate_api_key = AsyncMock( - return_value=SimpleNamespace( - workspace_uuid='w', - workspace=SimpleNamespace(permissions=frozenset({Permission.PROVIDER_SECRET_MANAGE.value})), - ) + context = RequestContext( + 'instance', + 1, + 'request', + 'api-key', + PrincipalContext(PrincipalType.API_KEY, api_key_uuid='key'), + WorkspaceContext('w', None, None, frozenset({Permission.PROVIDER_SECRET_MANAGE.value})), ) + from tests.utils.application_api import bind_application_api + + bind_application_api(ap, context) + router._authenticate_api_key = AsyncMock(return_value=context) await router.initialize() return app.test_client(), ap diff --git a/tests/utils/application_api.py b/tests/utils/application_api.py new file mode 100644 index 000000000..92a66b2d8 --- /dev/null +++ b/tests/utils/application_api.py @@ -0,0 +1,32 @@ +"""Bind the real Application API to focused consumer test applications.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from langbot.pkg.api.application import ApplicationAPI + + +@asynccontextmanager +async def _tenant_scope(workspace_uuid): + yield + + +def bind_application_api(ap, context): + """Fill missing execution infrastructure while preserving real test services.""" + if getattr(ap, 'workspace_service', None) is None: + ap.workspace_service = SimpleNamespace( + get_execution_binding=AsyncMock( + return_value=SimpleNamespace( + instance_uuid=context.instance_uuid, + workspace_uuid=context.workspace_uuid, + placement_generation=context.placement_generation, + ) + ) + ) + if getattr(ap, 'persistence_mgr', None) is None: + ap.persistence_mgr = SimpleNamespace(tenant_scope=_tenant_scope) + elif getattr(ap.persistence_mgr, 'tenant_scope', None) is None: + ap.persistence_mgr.tenant_scope = _tenant_scope + ap.application_api = ApplicationAPI(ap) + return ap.application_api