mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
591 lines
21 KiB
Python
591 lines
21 KiB
Python
from datetime import UTC, datetime
|
|
from typing import cast
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, SecretStr, field_validator, model_validator
|
|
from server.auth.authorization import get_user_super_role
|
|
from server.auth.org_context import EFFECTIVE_ORG_ID
|
|
from server.auth.saas_user_auth import SaasUserAuth
|
|
from server.constants import BYOR_KEY_ALIAS_PATTERN
|
|
from storage.api_key import ApiKey
|
|
from storage.api_key_store import ApiKeyStore
|
|
from storage.lite_llm_manager import LiteLlmManager
|
|
from storage.org_member import OrgMember
|
|
from storage.org_member_store import OrgMemberStore
|
|
from storage.org_service import OrgService
|
|
from storage.user_store import UserStore
|
|
|
|
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
|
from openhands.app_server.user_auth.user_auth import AuthType
|
|
from openhands.app_server.utils.logger import openhands_logger as logger
|
|
|
|
|
|
# Helper functions for BYOR API key management
|
|
async def get_byor_key_from_db(user_id: str, org_id: UUID) -> str | None:
|
|
"""Get the BYOR key from the database for a user in a specific org."""
|
|
user = await UserStore.get_user_by_id(user_id)
|
|
if not user:
|
|
return None
|
|
|
|
org_member: OrgMember | None = None
|
|
for member in user.org_members:
|
|
if member.org_id == org_id:
|
|
org_member = member
|
|
break
|
|
if not org_member:
|
|
return None
|
|
if org_member.llm_api_key_for_byor:
|
|
return org_member.llm_api_key_for_byor.get_secret_value()
|
|
return None
|
|
|
|
|
|
async def store_byor_key_in_db(user_id: str, org_id: UUID, key: str) -> None:
|
|
"""Store the BYOR key in the database for a user in a specific org."""
|
|
user = await UserStore.get_user_by_id(user_id)
|
|
if not user:
|
|
return None
|
|
|
|
org_member: OrgMember | None = None
|
|
for member in user.org_members:
|
|
if member.org_id == org_id:
|
|
org_member = member
|
|
break
|
|
if not org_member:
|
|
return None
|
|
org_member.llm_api_key_for_byor = SecretStr(key)
|
|
await OrgMemberStore.update_org_member(org_member)
|
|
|
|
|
|
def _create_byor_key_alias(user_id: str, org_id: str) -> str:
|
|
alias = BYOR_KEY_ALIAS_PATTERN.format(user_id=user_id, org_id=org_id)
|
|
return alias
|
|
|
|
|
|
async def generate_byor_key(user_id: str, org_id: UUID) -> str | None:
|
|
"""Generate a new BYOR key for a user in a specific org."""
|
|
try:
|
|
org_id_str = str(org_id)
|
|
key = await LiteLlmManager.generate_key(
|
|
user_id,
|
|
org_id_str,
|
|
_create_byor_key_alias(user_id, org_id_str),
|
|
{'type': 'byor'},
|
|
)
|
|
|
|
logger.info(
|
|
'Successfully generated new BYOR key',
|
|
extra={
|
|
'user_id': user_id,
|
|
'key_length': len(key),
|
|
'key_prefix': key[:10] + '...' if len(key) > 10 else key,
|
|
},
|
|
)
|
|
return key
|
|
except Exception:
|
|
logger.exception(
|
|
'Error generating BYOR key',
|
|
extra={
|
|
'user_id': user_id,
|
|
},
|
|
stack_info=True,
|
|
)
|
|
return None
|
|
|
|
|
|
async def delete_byor_key_from_litellm(
|
|
user_id: str, org_id: UUID, byor_key: str
|
|
) -> bool:
|
|
"""Delete the BYOR key from LiteLLM using the key directly.
|
|
|
|
Also attempts to delete by key alias if the key is not found,
|
|
to clean up orphaned aliases that could block key regeneration.
|
|
"""
|
|
try:
|
|
key_alias = _create_byor_key_alias(user_id, str(org_id))
|
|
await LiteLlmManager.delete_key(byor_key, key_alias=key_alias)
|
|
logger.info(
|
|
'Successfully deleted BYOR key from LiteLLM',
|
|
extra={'user_id': user_id},
|
|
)
|
|
return True
|
|
except Exception:
|
|
logger.exception(
|
|
'Error deleting BYOR key from LiteLLM',
|
|
extra={
|
|
'user_id': user_id,
|
|
},
|
|
stack_info=True,
|
|
)
|
|
return False
|
|
|
|
|
|
# Initialize API router and key store
|
|
api_router = APIRouter(prefix='/api/keys')
|
|
api_key_store = ApiKeyStore.get_instance()
|
|
|
|
|
|
class ApiKeyCreate(BaseModel):
|
|
name: str | None = None
|
|
not_before: datetime | None = None
|
|
expires_at: datetime | None = None
|
|
# Org the key is bound to. ``None`` (or omitted) creates an *unbound*
|
|
# key whose effective org is resolved per-request via the ``X-Org-Id``
|
|
# header or, as a fallback, the caller's ``user.current_org_id``. When
|
|
# set, the caller must be a member of the requested org.
|
|
org_id: UUID | None = None
|
|
|
|
@field_validator('expires_at')
|
|
def validate_expiration(cls, v):
|
|
if v and v < datetime.now(UTC):
|
|
raise ValueError('Expiration date cannot be in the past')
|
|
return v
|
|
|
|
@model_validator(mode='after')
|
|
def validate_active_window(self):
|
|
if (
|
|
self.not_before is not None
|
|
and self.expires_at is not None
|
|
and self.not_before >= self.expires_at
|
|
):
|
|
raise ValueError('not_before must be earlier than expires_at')
|
|
return self
|
|
|
|
|
|
class ApiKeyResponse(BaseModel):
|
|
id: int
|
|
name: str | None = None
|
|
created_at: datetime
|
|
last_used_at: datetime | None = None
|
|
not_before: datetime | None = None
|
|
expires_at: datetime | None = None
|
|
# ``None`` denotes an unbound key (scoped per-request via ``X-Org-Id``).
|
|
org_id: UUID | None = None
|
|
|
|
|
|
class ApiKeyCreateResponse(ApiKeyResponse):
|
|
key: str
|
|
|
|
|
|
class LlmApiKeyResponse(BaseModel):
|
|
key: str | None
|
|
|
|
|
|
class ByorPermittedResponse(BaseModel):
|
|
permitted: bool
|
|
|
|
|
|
class MessageResponse(BaseModel):
|
|
message: str
|
|
|
|
|
|
class CurrentApiKeyResponse(BaseModel):
|
|
"""Response model for the current API key endpoint.
|
|
|
|
``org_id`` is the *effective* org id of the current request: the
|
|
key's bound org for org-bound keys, or the resolved org (from the
|
|
``X-Org-Id`` header or ``user.current_org_id``) for unbound keys.
|
|
``bound_org_id`` distinguishes the two cases -- it is the org
|
|
persisted on the key, or ``None`` when the key is unbound.
|
|
"""
|
|
|
|
id: int
|
|
name: str | None
|
|
org_id: str
|
|
bound_org_id: str | None
|
|
user_id: str
|
|
auth_type: str
|
|
|
|
|
|
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
|
|
"""Convert an ApiKey model to an ApiKeyResponse."""
|
|
return ApiKeyResponse(
|
|
id=key.id,
|
|
name=key.name,
|
|
created_at=key.created_at,
|
|
last_used_at=key.last_used_at,
|
|
not_before=key.not_before,
|
|
expires_at=key.expires_at,
|
|
org_id=key.org_id,
|
|
)
|
|
|
|
|
|
@api_router.get('/llm/byor/permitted', tags=['Keys'])
|
|
async def check_byor_permitted(
|
|
user_id: str = Depends(get_user_id),
|
|
effective_org_id: UUID = EFFECTIVE_ORG_ID,
|
|
) -> ByorPermittedResponse:
|
|
"""Check if BYOR key export is permitted for the request's effective org."""
|
|
try:
|
|
permitted = await OrgService.check_byor_export_enabled(
|
|
user_id, org_id=effective_org_id
|
|
)
|
|
return ByorPermittedResponse(permitted=permitted)
|
|
except Exception as e:
|
|
logger.exception('Error checking BYOR export permission', stack_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to check BYOR export permission',
|
|
) from e
|
|
|
|
|
|
@api_router.post('', tags=['Keys'])
|
|
async def create_api_key(
|
|
key_data: ApiKeyCreate,
|
|
user_id: str = Depends(get_user_id),
|
|
effective_org_id: UUID = EFFECTIVE_ORG_ID,
|
|
) -> ApiKeyCreateResponse:
|
|
"""Create a new API key for the authenticated user.
|
|
|
|
The new key is bound to ``key_data.org_id`` when provided. An *omitted*
|
|
``org_id`` falls back to the request's effective org (preserving the
|
|
pre-existing API). An *explicit* ``org_id: null`` creates an unbound
|
|
key whose effective org is resolved per-request via the ``X-Org-Id``
|
|
header or, as a fallback, the caller's ``user.current_org_id``. When a
|
|
specific ``org_id`` is supplied, the caller must be a member of that
|
|
org (or hold a super role).
|
|
"""
|
|
if 'org_id' in key_data.model_fields_set:
|
|
# Caller expressed an explicit org choice -- ``null`` is meaningful
|
|
# (unbound key), an UUID requires a membership check.
|
|
target_org_id = key_data.org_id
|
|
else:
|
|
# Backwards-compatible default: bind to the effective org.
|
|
target_org_id = effective_org_id
|
|
|
|
if target_org_id is not None:
|
|
# Verify the caller is allowed to bind a key to this org.
|
|
try:
|
|
user_uuid = UUID(user_id)
|
|
except (TypeError, ValueError):
|
|
logger.warning('create_api_key_invalid_user_id', extra={'user_id': user_id})
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Invalid user id',
|
|
)
|
|
|
|
member = await OrgMemberStore.get_org_member(target_org_id, user_uuid)
|
|
if member is None:
|
|
# Super-role bypass mirrors ``_resolve_org_id``: a user with a
|
|
# cross-org "super" role can bind keys on behalf of orgs they
|
|
# have not joined. The route still requires the explicit
|
|
# ``org_id`` in the request body for this to apply.
|
|
super_role = await get_user_super_role(user_id)
|
|
if super_role is None:
|
|
logger.warning(
|
|
'create_api_key_not_a_member',
|
|
extra={'user_id': user_id, 'org_id': str(target_org_id)},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='User is not a member of the requested organization',
|
|
)
|
|
|
|
try:
|
|
api_key = await api_key_store.create_api_key(
|
|
user_id,
|
|
key_data.name,
|
|
expires_at=key_data.expires_at,
|
|
not_before=key_data.not_before,
|
|
org_id=target_org_id,
|
|
# We've already decided the binding above (explicit null for
|
|
# unbound, the supplied UUID, or the effective org when omitted)
|
|
# so disable the store's current-org fallback.
|
|
use_current_org_fallback=False,
|
|
)
|
|
# Look up the row we just inserted so the response reflects the
|
|
# persisted ``org_id`` (which may be ``None`` for unbound keys).
|
|
# ``list_api_keys`` returns both bound keys for ``target_org_id`` and
|
|
# any unbound keys; matching by name+org disambiguates when the
|
|
# caller reuses a name across different org scopes.
|
|
keys = await api_key_store.list_api_keys(user_id, org_id=target_org_id)
|
|
for key in keys:
|
|
if key.name == key_data.name and key.org_id == target_org_id:
|
|
return ApiKeyCreateResponse(
|
|
id=key.id,
|
|
name=key.name,
|
|
key=api_key,
|
|
created_at=key.created_at,
|
|
last_used_at=key.last_used_at,
|
|
not_before=key.not_before,
|
|
expires_at=key.expires_at,
|
|
org_id=key.org_id,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
logger.exception('Error creating API key', stack_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to create API key',
|
|
)
|
|
|
|
|
|
@api_router.get('', tags=['Keys'])
|
|
async def list_api_keys(
|
|
user_id: str = Depends(get_user_id),
|
|
effective_org_id: UUID = EFFECTIVE_ORG_ID,
|
|
) -> list[ApiKeyResponse]:
|
|
"""List API keys for the authenticated user in the effective org."""
|
|
try:
|
|
keys = await api_key_store.list_api_keys(user_id, org_id=effective_org_id)
|
|
return [api_key_to_response(key) for key in keys]
|
|
except Exception:
|
|
logger.exception('Error listing API keys', stack_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to list API keys',
|
|
)
|
|
|
|
|
|
@api_router.delete('/{key_id}', tags=['Keys'])
|
|
async def delete_api_key(
|
|
key_id: int,
|
|
user_id: str = Depends(get_user_id),
|
|
effective_org_id: UUID = EFFECTIVE_ORG_ID,
|
|
) -> MessageResponse:
|
|
"""Delete an API key, scoped to the effective org."""
|
|
try:
|
|
# First, verify the key belongs to the user in this org.
|
|
keys = await api_key_store.list_api_keys(user_id, org_id=effective_org_id)
|
|
key_to_delete = None
|
|
|
|
for key in keys:
|
|
if key.id == key_id:
|
|
key_to_delete = key
|
|
break
|
|
|
|
if not key_to_delete:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='API key not found',
|
|
)
|
|
|
|
# Delete the key
|
|
success = await api_key_store.delete_api_key_by_id(key_id)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to delete API key',
|
|
)
|
|
return MessageResponse(message='API key deleted successfully')
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
logger.exception('Error deleting API key', stack_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to delete API key',
|
|
)
|
|
|
|
|
|
@api_router.get('/current', tags=['Keys'])
|
|
async def get_current_api_key(
|
|
request: Request,
|
|
user_id: str = Depends(get_user_id),
|
|
) -> CurrentApiKeyResponse:
|
|
"""Get information about the currently authenticated API key.
|
|
|
|
Returns the key's bound org (``bound_org_id``, ``None`` for unbound
|
|
keys) and the request's effective org (``org_id``, resolved from the
|
|
``X-Org-Id`` header or ``user.current_org_id`` for unbound keys).
|
|
|
|
Returns 400 if not authenticated via API key (e.g., using cookie auth).
|
|
"""
|
|
user_auth = await get_user_auth(request)
|
|
|
|
# Check if authenticated via API key
|
|
if user_auth.get_auth_type() != AuthType.BEARER:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
|
|
)
|
|
|
|
# In SaaS context, bearer auth always produces SaasUserAuth
|
|
saas_user_auth = cast(SaasUserAuth, user_auth)
|
|
|
|
if saas_user_auth.api_key_id is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This endpoint requires API key authentication.',
|
|
)
|
|
# Resolve the effective org so unbound keys report the org they're
|
|
# actually operating in for this request.
|
|
effective_org_id = await saas_user_auth.get_effective_org_id()
|
|
return CurrentApiKeyResponse(
|
|
id=saas_user_auth.api_key_id,
|
|
name=saas_user_auth.api_key_name,
|
|
org_id=str(effective_org_id) if effective_org_id is not None else '',
|
|
bound_org_id=(
|
|
str(saas_user_auth.api_key_org_id)
|
|
if saas_user_auth.api_key_org_id is not None
|
|
else None
|
|
),
|
|
user_id=user_id,
|
|
auth_type=saas_user_auth.auth_type.value,
|
|
)
|
|
|
|
|
|
@api_router.get('/llm/byor', tags=['Keys'])
|
|
async def get_llm_api_key_for_byor(
|
|
user_id: str = Depends(get_user_id),
|
|
effective_org_id: UUID = EFFECTIVE_ORG_ID,
|
|
) -> LlmApiKeyResponse:
|
|
"""Get the LLM API key for BYOR (Bring Your Own Runtime).
|
|
|
|
This endpoint validates that the key exists in LiteLLM before returning it.
|
|
If validation fails, it automatically generates a new key to ensure users
|
|
always receive a working key.
|
|
|
|
Returns 402 Payment Required if BYOR export is not enabled for the
|
|
request's effective org.
|
|
"""
|
|
try:
|
|
if not await OrgService.check_byor_export_enabled(
|
|
user_id, org_id=effective_org_id
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
|
|
)
|
|
|
|
# Check if the BYOR key exists in the database
|
|
byor_key = await get_byor_key_from_db(user_id, effective_org_id)
|
|
if byor_key:
|
|
# Validate that the key is actually registered in LiteLLM
|
|
is_valid = await LiteLlmManager.verify_key(byor_key, user_id)
|
|
if is_valid:
|
|
return LlmApiKeyResponse(key=byor_key)
|
|
else:
|
|
# Key exists in DB but is invalid in LiteLLM - regenerate it
|
|
logger.warning(
|
|
'BYOR key found in database but invalid in LiteLLM - regenerating',
|
|
extra={
|
|
'user_id': user_id,
|
|
'key_prefix': byor_key[:10] + '...'
|
|
if len(byor_key) > 10
|
|
else byor_key,
|
|
},
|
|
)
|
|
# Delete the invalid key from LiteLLM (best effort, don't fail if it doesn't exist)
|
|
await delete_byor_key_from_litellm(user_id, effective_org_id, byor_key)
|
|
# Fall through to generate a new key
|
|
|
|
# Generate a new key for BYOR (either no key exists or validation failed)
|
|
key = await generate_byor_key(user_id, effective_org_id)
|
|
if key:
|
|
# Store the key in the database
|
|
await store_byor_key_in_db(user_id, effective_org_id, key)
|
|
logger.info(
|
|
'Successfully generated and stored new BYOR key',
|
|
extra={'user_id': user_id},
|
|
)
|
|
return LlmApiKeyResponse(key=key)
|
|
else:
|
|
logger.error(
|
|
'Failed to generate new BYOR LLM API key',
|
|
extra={'user_id': user_id},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to generate new BYOR LLM API key',
|
|
)
|
|
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions as-is
|
|
raise
|
|
except Exception as e:
|
|
logger.exception('Error retrieving BYOR LLM API key', stack_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to retrieve BYOR LLM API key',
|
|
) from e
|
|
|
|
|
|
@api_router.post('/llm/byor/refresh', tags=['Keys'])
|
|
async def refresh_llm_api_key_for_byor(
|
|
user_id: str = Depends(get_user_id),
|
|
effective_org_id: UUID = EFFECTIVE_ORG_ID,
|
|
) -> LlmApiKeyResponse:
|
|
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime).
|
|
|
|
Returns 402 Payment Required if BYOR export is not enabled for the
|
|
request's effective org.
|
|
"""
|
|
logger.info('Starting BYOR LLM API key refresh', extra={'user_id': user_id})
|
|
|
|
try:
|
|
if not await OrgService.check_byor_export_enabled(
|
|
user_id, org_id=effective_org_id
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
|
|
)
|
|
|
|
# Get the existing BYOR key from the database
|
|
existing_byor_key = await get_byor_key_from_db(user_id, effective_org_id)
|
|
|
|
# If we have an existing key, delete it from LiteLLM
|
|
if existing_byor_key:
|
|
delete_success = await delete_byor_key_from_litellm(
|
|
user_id, effective_org_id, existing_byor_key
|
|
)
|
|
if not delete_success:
|
|
logger.warning(
|
|
'Failed to delete existing BYOR key from LiteLLM, continuing with key generation',
|
|
extra={'user_id': user_id},
|
|
)
|
|
else:
|
|
logger.info(
|
|
'No existing BYOR key found in database, proceeding with key generation',
|
|
extra={'user_id': user_id},
|
|
)
|
|
|
|
# Generate a new key
|
|
key = await generate_byor_key(user_id, effective_org_id)
|
|
if not key:
|
|
logger.error(
|
|
'Failed to generate new BYOR LLM API key',
|
|
extra={'user_id': user_id},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to generate new BYOR LLM API key',
|
|
)
|
|
|
|
# Store the key in the database
|
|
await store_byor_key_in_db(user_id, effective_org_id, key)
|
|
|
|
logger.info(
|
|
'BYOR LLM API key refresh completed successfully',
|
|
extra={'user_id': user_id},
|
|
)
|
|
return LlmApiKeyResponse(key=key)
|
|
except HTTPException as he:
|
|
logger.exception(
|
|
'HTTP exception during BYOR LLM API key refresh',
|
|
extra={
|
|
'user_id': user_id,
|
|
'status_code': he.status_code,
|
|
'detail': he.detail,
|
|
'exception_type': type(he).__name__,
|
|
},
|
|
stack_info=True,
|
|
)
|
|
raise
|
|
except Exception as e:
|
|
logger.exception(
|
|
'Unexpected error refreshing BYOR LLM API key',
|
|
extra={
|
|
'user_id': user_id,
|
|
'exception_type': type(e).__name__,
|
|
},
|
|
stack_info=True,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to refresh BYOR LLM API key',
|
|
) from e
|