"""Endpoints router — CRUD for LLM endpoint configurations. Supports creating, listing, updating, and deleting LLM endpoint configs. API keys are stored encrypted using Fernet (key derived from JWT_SECRET). The test endpoint calls adapter.test_connection() and adapter.list_models(). """ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from auth import get_current_user from encryption import decrypt_api_key, encrypt_api_key from main import get_db from models import LLMEndpoint, User from engine.adapters.openai_compat import OpenAICompatAdapter from schemas import ( EndpointCreate, EndpointListResponse, EndpointResponse, EndpointUpdate, ) router = APIRouter() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_endpoint_or_404(db: Session, endpoint_id: uuid.UUID) -> LLMEndpoint: endpoint = db.query(LLMEndpoint).filter(LLMEndpoint.id == endpoint_id).first() if endpoint is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Endpoint not found") return endpoint # --------------------------------------------------------------------------- # CRUD # --------------------------------------------------------------------------- @router.get("/", response_model=EndpointListResponse) def list_endpoints( db: Session = Depends(get_db), _user: User = Depends(get_current_user), ) -> EndpointListResponse: """List all configured LLM endpoints.""" endpoints = db.query(LLMEndpoint).order_by(LLMEndpoint.name).all() return EndpointListResponse( items=[_to_response(ep) for ep in endpoints], total=len(endpoints), ) @router.post("/", response_model=EndpointResponse, status_code=status.HTTP_201_CREATED) def create_endpoint( body: EndpointCreate, db: Session = Depends(get_db), _user: User = Depends(get_current_user), ) -> EndpointResponse: """Create a new LLM endpoint configuration.""" endpoint = LLMEndpoint( name=body.name, url=body.url, api_key_encrypted=encrypt_api_key(body.api_key) if body.api_key else None, default_model=body.default_model, ) db.add(endpoint) db.commit() db.refresh(endpoint) return _to_response(endpoint) @router.get("/{endpoint_id}", response_model=EndpointResponse) def get_endpoint( endpoint_id: uuid.UUID, db: Session = Depends(get_db), _user: User = Depends(get_current_user), ) -> EndpointResponse: """Get a single LLM endpoint configuration.""" endpoint = _get_endpoint_or_404(db, endpoint_id) return _to_response(endpoint) @router.put("/{endpoint_id}", response_model=EndpointResponse) def update_endpoint( endpoint_id: uuid.UUID, body: EndpointUpdate, db: Session = Depends(get_db), _user: User = Depends(get_current_user), ) -> EndpointResponse: """Update an LLM endpoint configuration.""" endpoint = _get_endpoint_or_404(db, endpoint_id) if body.name is not None: endpoint.name = body.name if body.url is not None: endpoint.url = body.url if body.api_key is not None: # Empty string clears the key; non-empty encrypts it endpoint.api_key_encrypted = encrypt_api_key(body.api_key) if body.api_key else None if body.default_model is not None: endpoint.default_model = body.default_model db.commit() db.refresh(endpoint) return _to_response(endpoint) @router.delete("/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_endpoint( endpoint_id: uuid.UUID, db: Session = Depends(get_db), _user: User = Depends(get_current_user), ) -> None: """Delete an LLM endpoint configuration.""" endpoint = _get_endpoint_or_404(db, endpoint_id) db.delete(endpoint) db.commit() # --------------------------------------------------------------------------- # Test connection # --------------------------------------------------------------------------- @router.post("/{endpoint_id}/test") async def test_endpoint( endpoint_id: uuid.UUID, db: Session = Depends(get_db), _user: User = Depends(get_current_user), ) -> dict: """Test connectivity and list available models for an endpoint. Calls adapter.test_connection() and adapter.list_models() against the stored endpoint configuration. """ endpoint = _get_endpoint_or_404(db, endpoint_id) api_key: str | None = None if endpoint.api_key_encrypted: try: api_key = decrypt_api_key(endpoint.api_key_encrypted) except ValueError: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decrypt API key — JWT_SECRET may have changed", ) adapter = OpenAICompatAdapter(base_url=endpoint.url, api_key=api_key) connected = await adapter.test_connection() models: list[str] = [] if connected: try: models = await adapter.list_models() except Exception: pass # Connection worked but model listing failed return { "endpoint_id": str(endpoint.id), "name": endpoint.name, "connected": connected, "models": models, } # --------------------------------------------------------------------------- # Response builder # --------------------------------------------------------------------------- def _to_response(endpoint: LLMEndpoint) -> EndpointResponse: """Convert ORM model to response schema (never expose encrypted key).""" return EndpointResponse( id=endpoint.id, name=endpoint.name, url=endpoint.url, default_model=endpoint.default_model, has_api_key=endpoint.api_key_encrypted is not None, )