mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
"""Event Callback router for OpenHands Server."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.security import APIKeyHeader
|
|
from jwt import InvalidTokenError
|
|
|
|
from openhands.agent_server.models import ConversationInfo, Success
|
|
from openhands.app_server.app_conversation.app_conversation_info_service import (
|
|
AppConversationInfoService,
|
|
)
|
|
from openhands.app_server.app_conversation.app_conversation_models import (
|
|
AppConversationInfo,
|
|
)
|
|
from openhands.app_server.config import (
|
|
depends_app_conversation_info_service,
|
|
depends_db_session,
|
|
depends_event_service,
|
|
depends_jwt_service,
|
|
depends_sandbox_service,
|
|
get_event_callback_service,
|
|
get_global_config,
|
|
)
|
|
from openhands.app_server.errors import AuthError
|
|
from openhands.app_server.event.event_service import EventService
|
|
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
|
|
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
|
from openhands.app_server.services.injector import InjectorState
|
|
from openhands.app_server.services.jwt_service import JwtService
|
|
from openhands.app_server.user.specifiy_user_context import (
|
|
USER_CONTEXT_ATTR,
|
|
SpecifyUserContext,
|
|
as_admin,
|
|
)
|
|
from openhands.app_server.user.user_context import UserContext
|
|
from openhands.integrations.provider import ProviderType
|
|
from openhands.sdk import Event
|
|
|
|
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
|
|
sandbox_service_dependency = depends_sandbox_service()
|
|
event_service_dependency = depends_event_service()
|
|
app_conversation_info_service_dependency = depends_app_conversation_info_service()
|
|
jwt_dependency = depends_jwt_service()
|
|
config = get_global_config()
|
|
db_session_dependency = depends_db_session()
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def valid_sandbox(
|
|
sandbox_id: str,
|
|
user_context: UserContext = Depends(as_admin),
|
|
session_api_key: str = Depends(
|
|
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
|
|
),
|
|
sandbox_service: SandboxService = sandbox_service_dependency,
|
|
) -> SandboxInfo:
|
|
sandbox_info = await sandbox_service.get_sandbox(sandbox_id)
|
|
if sandbox_info is None or sandbox_info.session_api_key != session_api_key:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
return sandbox_info
|
|
|
|
|
|
async def valid_conversation(
|
|
conversation_id: UUID,
|
|
sandbox_info: SandboxInfo,
|
|
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
|
) -> AppConversationInfo:
|
|
app_conversation_info = (
|
|
await app_conversation_info_service.get_app_conversation_info(conversation_id)
|
|
)
|
|
if not app_conversation_info:
|
|
# Conversation does not yet exist - create a stub
|
|
return AppConversationInfo(
|
|
id=conversation_id,
|
|
sandbox_id=sandbox_info.id,
|
|
created_by_user_id=sandbox_info.created_by_user_id,
|
|
)
|
|
if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id:
|
|
# Make sure that the conversation and sandbox were created by the same user
|
|
raise AuthError()
|
|
return app_conversation_info
|
|
|
|
|
|
@router.post('/{sandbox_id}/conversations')
|
|
async def on_conversation_update(
|
|
conversation_info: ConversationInfo,
|
|
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
|
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
|
) -> Success:
|
|
"""Webhook callback for when a conversation starts, pauses, resumes, or deletes."""
|
|
existing = await valid_conversation(
|
|
conversation_info.id, sandbox_info, app_conversation_info_service
|
|
)
|
|
|
|
app_conversation_info = AppConversationInfo(
|
|
id=conversation_info.id,
|
|
# TODO: As of writing, ConversationInfo from AgentServer does not have a title
|
|
title=existing.title or f'Conversation {conversation_info.id}',
|
|
sandbox_id=sandbox_info.id,
|
|
created_by_user_id=sandbox_info.created_by_user_id,
|
|
llm_model=conversation_info.agent.llm.model,
|
|
# Git parameters
|
|
selected_repository=existing.selected_repository,
|
|
selected_branch=existing.selected_branch,
|
|
git_provider=existing.git_provider,
|
|
trigger=existing.trigger,
|
|
pr_number=existing.pr_number,
|
|
)
|
|
await app_conversation_info_service.save_app_conversation_info(
|
|
app_conversation_info
|
|
)
|
|
|
|
return Success()
|
|
|
|
|
|
@router.post('/{sandbox_id}/events/{conversation_id}')
|
|
async def on_event(
|
|
events: list[Event],
|
|
conversation_id: UUID,
|
|
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
|
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
|
event_service: EventService = event_service_dependency,
|
|
) -> Success:
|
|
"""Webhook callback for when event stream events occur."""
|
|
|
|
app_conversation_info = await valid_conversation(
|
|
conversation_id, sandbox_info, app_conversation_info_service
|
|
)
|
|
|
|
try:
|
|
# Save events...
|
|
await asyncio.gather(
|
|
*[event_service.save_event(conversation_id, event) for event in events]
|
|
)
|
|
|
|
asyncio.create_task(
|
|
_run_callbacks_in_bg_and_close(
|
|
conversation_id, app_conversation_info.created_by_user_id, events
|
|
)
|
|
)
|
|
|
|
except Exception:
|
|
_logger.exception('Error in webhook', stack_info=True)
|
|
|
|
return Success()
|
|
|
|
|
|
@router.get('/secrets')
|
|
async def get_secret(
|
|
access_token: str = Depends(APIKeyHeader(name='X-Access-Token', auto_error=False)),
|
|
jwt_service: JwtService = jwt_dependency,
|
|
) -> str:
|
|
"""Given an access token, retrieve a user secret. The access token
|
|
is limited by user and provider type, and may include a timeout, limiting
|
|
the damage in the event that a token is ever leaked"""
|
|
try:
|
|
payload = jwt_service.verify_jws_token(access_token)
|
|
user_id = payload['user_id']
|
|
provider_type = ProviderType[payload['provider_type']]
|
|
user_injector = config.user
|
|
assert user_injector is not None
|
|
user_context = await user_injector.get_for_user(user_id)
|
|
secret = None
|
|
if user_context:
|
|
secret = await user_context.get_latest_token(provider_type)
|
|
if secret is None:
|
|
raise HTTPException(404, 'No such provider')
|
|
return secret
|
|
except InvalidTokenError:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
|
|
|
|
async def _run_callbacks_in_bg_and_close(
|
|
conversation_id: UUID,
|
|
user_id: str | None,
|
|
events: list[Event],
|
|
):
|
|
"""Run all callbacks and close the session"""
|
|
state = InjectorState()
|
|
setattr(state, USER_CONTEXT_ATTR, SpecifyUserContext(user_id=user_id))
|
|
|
|
async with get_event_callback_service(state) as event_callback_service:
|
|
# We don't use asynio.gather here because callbacks must be run in sequence.
|
|
for event in events:
|
|
await event_callback_service.execute_callbacks(conversation_id, event)
|