Skip to content
Open
3 changes: 2 additions & 1 deletion docs/auth/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ authorization:
| `query` | Submit queries | `/v1/query` |
| `streaming_query` | Submit streaming queries | `/v1/streaming_query` |
| `info` | Access service info | `/`, `/info`, `/readiness`, `/liveness` |
| `get_config` | View configuration | `/config` |
| `get_config` | View configuration | `/v1/config`, `/v1/saved-prompts/config` |
| `get_models` | List available models | `/models` |
| `get_tools` | List available tools | `/tools`, `/mcp-auth/client-options` |
| `get_shields` | List safety shields | `/shields` |
Expand All @@ -111,6 +111,7 @@ authorization:
| `model_override` | Override model in queries | N/A (permission flag) |
| `read_prompts` | List and get prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` |
| `manage_prompts` | Manage prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` |
| `manage_saved_prompts` | Manage own saved prompts | `/v1/saved-prompts`, `/v1/saved-prompts/{prompt_id}` |

### Conversation Actions

Expand Down
3 changes: 3 additions & 0 deletions docs/devel_doc/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ The system defines 30+ actions that can be authorized. Examples (see `docs/auth.
- `GET_CONVERSATION` - Get conversation details
- `DELETE_CONVERSATION` - Delete conversations

**Saved Prompts:**
- `MANAGE_SAVED_PROMPTS` - Manage user's saved prompts (list, create, delete)

**Administrative Actions:**
- `ADMIN` - Administrative operations
- `FEEDBACK` - Submit user feedback
Expand Down
930 changes: 858 additions & 72 deletions docs/devel_doc/openapi.json

Large diffs are not rendered by default.

307 changes: 289 additions & 18 deletions src/app/endpoints/saved_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,71 @@

from typing import Annotated, Any

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy.exc import SQLAlchemyError

from authentication import get_auth_dependency
from authentication.interface import AuthTuple
from authorization.middleware import authorize
from configuration import configuration
from log import get_logger
from models.api.requests import SavedPromptCreateRequest
from models.api.responses.constants import UNAUTHORIZED_OPENAPI_EXAMPLES
from models.api.responses.error import (
BadRequestResponse,
ConflictResponse,
ForbiddenResponse,
InternalServerErrorResponse,
NotFoundResponse,
ServiceUnavailableResponse,
UnauthorizedResponse,
UnprocessableEntityResponse,
)
from models.api.responses.successful import (
SavedPromptResponse,
SavedPromptsConfigResponse,
SavedPromptsListResponse,
)
from models.api.responses.successful import SavedPromptsConfigResponse
from models.config import Action
from models.database.saved_prompts import SavedPrompt
from utils.endpoints import check_configuration_loaded
from utils.saved_prompts import (
SavedPromptAccessDeniedError,
SavedPromptConflictError,
SavedPromptLimitExceededError,
SavedPromptNotFoundError,
SavedPromptValidationError,
create_saved_prompt,
delete_saved_prompt_by_id_and_user,
list_saved_prompts_by_user,
validate_saved_prompt_content,
validate_saved_prompt_name,
)
from utils.suid import check_suid

logger = get_logger(__name__)
router = APIRouter(tags=["saved-prompts"])


def _to_saved_prompt_response(row: SavedPrompt) -> SavedPromptResponse:
"""Map a persisted saved-prompt row to the API response model.

Parameters:
row: Saved prompt entity loaded from the database.

Returns:
API response for a single saved prompt (excludes ``user_id``).
"""
return SavedPromptResponse(
id=row.id,
name=row.name,
content=row.content,
created_at=row.created_at,
updated_at=row.updated_at,
)


get_saved_prompts_config_responses: dict[int | str, dict[str, Any]] = {
200: SavedPromptsConfigResponse.openapi_response(),
401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES),
Expand All @@ -32,6 +75,39 @@
503: ServiceUnavailableResponse.openapi_response(examples=["kubernetes api"]),
}

list_saved_prompts_responses: dict[int | str, dict[str, Any]] = {
200: SavedPromptsListResponse.openapi_response(),
401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES),
403: ForbiddenResponse.openapi_response(examples=["endpoint"]),
500: InternalServerErrorResponse.openapi_response(
examples=["configuration", "database"]
),
}

create_saved_prompts_responses: dict[int | str, dict[str, Any]] = {
201: SavedPromptResponse.openapi_response(),
401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES),
403: ForbiddenResponse.openapi_response(examples=["endpoint"]),
409: ConflictResponse.openapi_response(examples=["saved prompt"]),
422: UnprocessableEntityResponse.openapi_response(
examples=["saved prompt invalid", "saved prompt limit"]
),
500: InternalServerErrorResponse.openapi_response(
examples=["configuration", "database"]
),
}

delete_saved_prompts_responses: dict[int | str, dict[str, Any]] = {
204: {"description": "Saved prompt deleted"},
400: BadRequestResponse.openapi_response(examples=["saved_prompt_id"]),
401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES),
403: ForbiddenResponse.openapi_response(examples=["endpoint"]),
404: NotFoundResponse.openapi_response(examples=["saved prompt"]),
500: InternalServerErrorResponse.openapi_response(
examples=["configuration", "database"]
),
}


@router.get("/saved-prompts/config", responses=get_saved_prompts_config_responses)
@authorize(Action.GET_CONFIG)
Expand All @@ -56,7 +132,7 @@ async def get_saved_prompts_config_handler(
- HTTPException: with status 401 for unauthorized access.
- HTTPException: with status 403 if permission is denied.
- HTTPException: with status 500 and a detail object containing `response`
and `cause` when service configuration is wrong or incomplete.
and `cause` when service configuration is not loaded.
- HTTPException: with status 503 and a detail object containing `response`
and `cause` when unable to connect to backend services.

Expand All @@ -69,20 +145,215 @@ async def get_saved_prompts_config_handler(
check_configuration_loaded(configuration)

saved_prompts_config = configuration.configuration.saved_prompts
max_prompts_per_user = saved_prompts_config.max_prompts_per_user
max_display_name_length = saved_prompts_config.max_display_name_length
max_content_length = saved_prompts_config.max_content_length
if (
max_prompts_per_user is None
or max_display_name_length is None
or max_content_length is None
):
logger.error("Saved prompts configuration limits are not set")
error_response = InternalServerErrorResponse.generic()
raise HTTPException(**error_response.model_dump())

return SavedPromptsConfigResponse(
max_prompts_per_user=max_prompts_per_user,
max_display_name_length=max_display_name_length,
max_content_length=max_content_length,
max_prompts_per_user=saved_prompts_config.max_prompts_per_user,
max_display_name_length=saved_prompts_config.max_display_name_length,
max_content_length=saved_prompts_config.max_content_length,
)


@router.get("/saved-prompts", responses=list_saved_prompts_responses)
@authorize(Action.MANAGE_SAVED_PROMPTS)
async def list_saved_prompts_handler(
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
request: Request,
) -> SavedPromptsListResponse:
"""
Handle requests to the GET /saved-prompts endpoint.

Process GET requests that return all saved prompts belonging to the
authenticated user, ordered by creation timestamp descending. For example:

curl http://localhost:8080/v1/saved-prompts

### Parameters:
- request: The incoming HTTP request (used by middleware).
- auth: Authentication tuple from the auth dependency.

### Raises:
- HTTPException: with status 401 for unauthorized access.
- HTTPException: with status 403 if permission is denied.
- HTTPException: with status 500 when configuration is not loaded or the
database query fails.

### Returns:
- SavedPromptsListResponse: Saved prompts for the authenticated user.
"""
_ = request
check_configuration_loaded(configuration)

user_id = auth[0]
logger.info("Retrieving saved prompts")

try:
rows = await run_in_threadpool(list_saved_prompts_by_user, user_id)
prompts = [_to_saved_prompt_response(row) for row in rows]
except SQLAlchemyError as exc:
logger.exception("Error retrieving saved prompts")
error_response = InternalServerErrorResponse.database_error()
raise HTTPException(**error_response.model_dump()) from exc

logger.info("Retrieved %s saved prompts", len(prompts))
return SavedPromptsListResponse(prompts=prompts)


@router.post(
"/saved-prompts",
responses=create_saved_prompts_responses,
status_code=status.HTTP_201_CREATED,
)
@authorize(Action.MANAGE_SAVED_PROMPTS)
async def create_saved_prompts_handler(
request: Request,
body: SavedPromptCreateRequest,
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
) -> SavedPromptResponse:
r"""
Handle requests to the POST /saved-prompts endpoint.

Process POST requests that create a saved prompt for the authenticated
user after validating name/content against configured limits. For example:

curl -X POST http://localhost:8080/v1/saved-prompts \
-H 'Content-Type: application/json' \
-d '{"name":"Deploy to staging","content":"Help me write a checklist"}'

### Parameters:
- request: The incoming HTTP request (used by middleware).
- body: Saved prompt name and content.
- auth: Authentication tuple from the auth dependency.

### Raises:
- HTTPException: with status 401 for unauthorized access.
- HTTPException: with status 403 if permission is denied.
- HTTPException: with status 409 when a prompt with the same name exists.
- HTTPException: with status 422 when validation fails or the per-user
limit would be exceeded.
- HTTPException: with status 500 when configuration is not loaded or the
database write fails.

### Returns:
- SavedPromptResponse: The created saved prompt.
"""
_ = request
check_configuration_loaded(configuration)

saved_prompts_config = configuration.configuration.saved_prompts

try:
name = validate_saved_prompt_name(
body.name,
max_display_name_length=saved_prompts_config.max_display_name_length,
)
validate_saved_prompt_content(
body.content,
max_content_length=saved_prompts_config.max_content_length,
)
except SavedPromptValidationError as exc:
error_response = UnprocessableEntityResponse(
response="Invalid attribute value",
cause=str(exc),
)
raise HTTPException(**error_response.model_dump()) from exc

user_id = auth[0]
logger.info("Creating saved prompt")

try:
row = await run_in_threadpool(
create_saved_prompt,
user_id,
name,
body.content,
saved_prompts_config.max_prompts_per_user,
)
except SavedPromptLimitExceededError as exc:
error_response = UnprocessableEntityResponse(
response="Saved prompt limit exceeded",
cause=str(exc),
)
raise HTTPException(**error_response.model_dump()) from exc
except SavedPromptConflictError as exc:
error_response = ConflictResponse(
resource="Saved prompt",
resource_id=name,
)
raise HTTPException(**error_response.model_dump()) from exc
except SQLAlchemyError as exc:
logger.exception("Error creating saved prompt")
error_response = InternalServerErrorResponse.database_error()
raise HTTPException(**error_response.model_dump()) from exc

logger.info("Created saved prompt id=%s", row.id)
return _to_saved_prompt_response(row)


@router.delete(
"/saved-prompts/{prompt_id}",
responses=delete_saved_prompts_responses,
status_code=status.HTTP_204_NO_CONTENT,
)
@authorize(Action.MANAGE_SAVED_PROMPTS)
async def delete_saved_prompts_handler(
request: Request,
prompt_id: str,
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
) -> Response:
"""
Handle requests to the DELETE /saved-prompts/{prompt_id} endpoint.

Process DELETE requests that remove a saved prompt belonging to the
authenticated user. Missing prompts and prompts owned by another user
both return 404 so foreign ids cannot be probed. For example:

curl -X DELETE http://localhost:8080/v1/saved-prompts/{prompt_id}

### Parameters:
- request: The incoming HTTP request (used by middleware).
- prompt_id: Identifier of the saved prompt to delete.
- auth: Authentication tuple from the auth dependency.

### Raises:
- HTTPException: with status 400 when ``prompt_id`` has an invalid format.
- HTTPException: with status 401 for unauthorized access.
- HTTPException: with status 403 if permission is denied.
- HTTPException: with status 404 when the prompt does not exist for this user.
- HTTPException: with status 500 when configuration is not loaded or the
database delete fails.

### Returns:
- Response: Empty 204 No Content on successful deletion.
"""
_ = request
check_configuration_loaded(configuration)

if not check_suid(prompt_id):
logger.error("Invalid saved prompt ID format: %s", prompt_id)
error_response = BadRequestResponse(
resource="saved prompt",
resource_id=prompt_id,
)
raise HTTPException(**error_response.model_dump())

user_id = auth[0]
logger.info("Deleting saved prompt id=%s", prompt_id)

try:
await run_in_threadpool(
delete_saved_prompt_by_id_and_user,
prompt_id,
user_id,
)
except (SavedPromptNotFoundError, SavedPromptAccessDeniedError) as exc:
error_response = NotFoundResponse(
resource="saved prompt",
resource_id=prompt_id,
)
raise HTTPException(**error_response.model_dump()) from exc
except SQLAlchemyError as exc:
logger.exception("Error deleting saved prompt id=%s", prompt_id)
error_response = InternalServerErrorResponse.database_error()
raise HTTPException(**error_response.model_dump()) from exc

logger.info("Deleted saved prompt id=%s", prompt_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
4 changes: 4 additions & 0 deletions src/models/api/requests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Request model for the OpenAI-compatible Responses API.
## [rlsapi.py](rlsapi.py)
Models for rlsapi v1 REST API requests.

## [saved_prompts.py](saved_prompts.py)

Request models for saved prompts endpoints.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## [vector_stores.py](vector_stores.py)
Request models for vector store and file endpoints.

Loading
Loading