open-notebook/api/routers/credentials.py
Luis Novo ba01f7df4e fix: handle credential decryption errors gracefully (#740)
- Credential.get_all() now uses per-row error handling instead of failing on first bad row
- Broken credentials include decryption_error field with descriptive message
- DELETE endpoint falls back to direct DB delete when credential can't be decrypted
- Frontend shows amber warning alert for broken credentials with disabled test/edit/discover
- Added i18n translation keys for decryption error warning in all 9 locales
2026-04-12 21:22:37 -03:00

410 lines
15 KiB
Python

"""
Credentials Router
Thin HTTP layer for managing individual AI provider credentials.
Business logic lives in api.credentials_service.
Endpoints:
- GET /credentials - List all credentials
- GET /credentials/by-provider/{provider} - List credentials for a provider
- POST /credentials - Create a new credential
- GET /credentials/{credential_id} - Get a specific credential
- PUT /credentials/{credential_id} - Update a credential
- DELETE /credentials/{credential_id} - Delete a credential
- POST /credentials/{credential_id}/test - Test connection
- POST /credentials/{credential_id}/discover - Discover models
- POST /credentials/{credential_id}/register-models - Register models
NEVER returns actual API key values - only metadata.
"""
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import SecretStr
from api.credentials_service import (
credential_to_response,
discover_with_config,
get_provider_status,
register_models,
require_encryption_key,
validate_url,
)
from api.credentials_service import (
get_env_status as svc_get_env_status,
)
from api.credentials_service import (
migrate_from_env as svc_migrate_from_env,
)
from api.credentials_service import (
migrate_from_provider_config as svc_migrate_from_provider_config,
)
from api.credentials_service import (
test_credential as svc_test_credential,
)
from api.models import (
CreateCredentialRequest,
CredentialDeleteResponse,
CredentialResponse,
DiscoveredModelResponse,
DiscoverModelsResponse,
RegisterModelsRequest,
RegisterModelsResponse,
UpdateCredentialRequest,
)
from open_notebook.database.repository import ensure_record_id, repo_delete, repo_query
from open_notebook.domain.credential import Credential
router = APIRouter(prefix="/credentials", tags=["credentials"])
def _handle_value_error(e: ValueError, status_code: int = 400) -> HTTPException:
"""Convert a ValueError from the service layer to an HTTPException."""
return HTTPException(status_code=status_code, detail=str(e))
# =============================================================================
# Status endpoints
# =============================================================================
@router.get("/status")
async def get_status():
"""
Get configuration status: encryption key status, and per-provider
configured/source information.
"""
try:
return await get_provider_status()
except Exception as e:
logger.error(f"Error fetching status: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch credential status")
@router.get("/env-status")
async def get_env_status():
"""Check what's configured via environment variables."""
try:
return await svc_get_env_status()
except Exception as e:
logger.error(f"Error checking env status: {e}")
raise HTTPException(status_code=500, detail="Failed to check environment status")
# =============================================================================
# CRUD endpoints
# =============================================================================
@router.get("", response_model=List[CredentialResponse])
async def list_credentials(
provider: Optional[str] = Query(None, description="Filter by provider"),
):
"""List all credentials, optionally filtered by provider."""
try:
if provider:
credentials = await Credential.get_by_provider(provider)
else:
credentials = await Credential.get_all(order_by="provider, created")
result = []
for cred in credentials:
models = await cred.get_linked_models()
result.append(credential_to_response(cred, len(models)))
return result
except Exception as e:
logger.error(f"Error listing credentials: {e}")
raise HTTPException(status_code=500, detail="Failed to list credentials")
@router.get("/by-provider/{provider}", response_model=List[CredentialResponse])
async def list_credentials_by_provider(provider: str):
"""List all credentials for a specific provider."""
try:
credentials = await Credential.get_by_provider(provider.lower())
result = []
for cred in credentials:
models = await cred.get_linked_models()
result.append(credential_to_response(cred, len(models)))
return result
except Exception as e:
logger.error(f"Error listing credentials for {provider}: {e}")
raise HTTPException(status_code=500, detail="Failed to list credentials for provider")
@router.post("", response_model=CredentialResponse, status_code=201)
async def create_credential(request: CreateCredentialRequest):
"""Create a new credential."""
try:
require_encryption_key()
except ValueError as e:
raise _handle_value_error(e)
# Validate all URL fields
for url_field in [
request.base_url, request.endpoint, request.endpoint_llm,
request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
]:
if url_field:
try:
validate_url(url_field, request.provider)
except ValueError as e:
raise _handle_value_error(e)
try:
cred = Credential(
name=request.name,
provider=request.provider.lower(),
modalities=request.modalities,
api_key=SecretStr(request.api_key) if request.api_key else None,
base_url=request.base_url,
endpoint=request.endpoint,
api_version=request.api_version,
endpoint_llm=request.endpoint_llm,
endpoint_embedding=request.endpoint_embedding,
endpoint_stt=request.endpoint_stt,
endpoint_tts=request.endpoint_tts,
project=request.project,
location=request.location,
credentials_path=request.credentials_path,
)
await cred.save()
return credential_to_response(cred, 0)
except Exception as e:
logger.error(f"Error creating credential: {e}")
raise HTTPException(status_code=500, detail="Failed to create credential")
@router.get("/{credential_id}", response_model=CredentialResponse)
async def get_credential(credential_id: str):
"""Get a specific credential by ID. Never returns api_key."""
try:
cred = await Credential.get(credential_id)
models = await cred.get_linked_models()
return credential_to_response(cred, len(models))
except Exception as e:
logger.error(f"Error fetching credential {credential_id}: {e}")
raise HTTPException(status_code=404, detail="Credential not found")
@router.put("/{credential_id}", response_model=CredentialResponse)
async def update_credential(credential_id: str, request: UpdateCredentialRequest):
"""Update an existing credential."""
try:
require_encryption_key()
except ValueError as e:
raise _handle_value_error(e)
# Validate all URL fields being updated
for url_field in [
request.base_url, request.endpoint, request.endpoint_llm,
request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
]:
if url_field:
try:
validate_url(url_field, "update")
except ValueError as e:
raise _handle_value_error(e)
try:
cred = await Credential.get(credential_id)
if request.name is not None:
cred.name = request.name
if request.modalities is not None:
cred.modalities = request.modalities
if request.api_key is not None:
cred.api_key = SecretStr(request.api_key)
if request.base_url is not None:
cred.base_url = request.base_url or None
if request.endpoint is not None:
cred.endpoint = request.endpoint or None
if request.api_version is not None:
cred.api_version = request.api_version or None
if request.endpoint_llm is not None:
cred.endpoint_llm = request.endpoint_llm or None
if request.endpoint_embedding is not None:
cred.endpoint_embedding = request.endpoint_embedding or None
if request.endpoint_stt is not None:
cred.endpoint_stt = request.endpoint_stt or None
if request.endpoint_tts is not None:
cred.endpoint_tts = request.endpoint_tts or None
if request.project is not None:
cred.project = request.project or None
if request.location is not None:
cred.location = request.location or None
if request.credentials_path is not None:
cred.credentials_path = request.credentials_path or None
await cred.save()
models = await cred.get_linked_models()
return credential_to_response(cred, len(models))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to update credential")
@router.delete("/{credential_id}", response_model=CredentialDeleteResponse)
async def delete_credential(
credential_id: str,
migrate_to: Optional[str] = Query(
None, description="Migrate linked models to this credential ID"
),
):
"""
Delete a credential.
If the credential has linked models:
- Pass migrate_to=<credential_id> to reassign them to another credential
- Otherwise, linked models are cascade-deleted automatically
"""
try:
try:
cred = await Credential.get(credential_id)
except Exception as decrypt_err:
# Credential exists but can't be decrypted (wrong encryption key).
# Fall back to direct DB operations for deletion.
logger.warning(
f"Cannot decrypt credential {credential_id}, "
f"falling back to direct delete: {decrypt_err}"
)
# Delete linked models directly
linked = await repo_query(
"SELECT * FROM model WHERE credential = $cred_id",
{"cred_id": ensure_record_id(credential_id)},
)
deleted_models = 0
for model_row in linked:
model_id = str(model_row.get("id", ""))
if model_id:
await repo_delete(model_id)
deleted_models += 1
# Delete the credential itself
await repo_delete(credential_id)
return CredentialDeleteResponse(
message="Credential deleted successfully",
deleted_models=deleted_models,
)
linked_models = await cred.get_linked_models()
deleted_models = 0
if linked_models and migrate_to:
# Migrate models to another credential
target_cred = await Credential.get(migrate_to)
for model in linked_models:
model.credential = target_cred.id
await model.save()
elif linked_models:
# Cascade-delete linked models (default behavior when no migrate_to)
for model in linked_models:
await model.delete()
deleted_models += 1
# Delete the credential
await cred.delete()
return CredentialDeleteResponse(
message="Credential deleted successfully",
deleted_models=deleted_models,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to delete credential")
# =============================================================================
# Test / Discover / Register endpoints
# =============================================================================
@router.post("/{credential_id}/test")
async def test_credential(credential_id: str):
"""Test connection using this credential's configuration."""
return await svc_test_credential(credential_id)
@router.post("/{credential_id}/discover", response_model=DiscoverModelsResponse)
async def discover_models_for_credential(credential_id: str):
"""Discover available models using this credential's API key."""
try:
cred = await Credential.get(credential_id)
config = cred.to_esperanto_config()
provider = cred.provider.lower()
discovered = await discover_with_config(provider, config)
return DiscoverModelsResponse(
credential_id=cred.id or "",
provider=provider,
discovered=[
DiscoveredModelResponse(
name=d["name"],
provider=d["provider"],
description=d.get("description"),
)
for d in discovered
],
)
except Exception as e:
logger.error(f"Error discovering models for credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to discover models")
@router.post("/{credential_id}/register-models", response_model=RegisterModelsResponse)
async def register_models_for_credential(
credential_id: str, request: RegisterModelsRequest
):
"""Register discovered models and link them to this credential."""
try:
result = await register_models(credential_id, request.models)
return RegisterModelsResponse(**result)
except Exception as e:
logger.error(f"Error registering models for credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to register models")
# =============================================================================
# Migration endpoints
# =============================================================================
@router.post("/migrate-from-provider-config")
async def migrate_from_provider_config():
"""Migrate existing ProviderConfig data to individual credential records."""
try:
return await svc_migrate_from_provider_config()
except ValueError as e:
raise _handle_value_error(e)
except Exception as e:
logger.error(f"ProviderConfig migration FAILED: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Migration from provider config failed")
@router.post("/migrate-from-env")
async def migrate_from_env():
"""Migrate API keys from environment variables to credential records."""
try:
return await svc_migrate_from_env()
except ValueError as e:
raise _handle_value_error(e)
except Exception as e:
logger.error(f"Env migration FAILED: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Migration from environment variables failed")