Skip to content

Commit

Permalink
feat: add user level open ai key management (#805)
Browse files Browse the repository at this point in the history
* feat: add user user identity table

* feat: add user openai api key input

* feat: add encryption missing message

* chore: log more details about 422 errors

* docs(API): update api creation path

* feat: use user openai key if defined
  • Loading branch information
mamadoudicko committed Aug 1, 2023
1 parent b72139a commit 7532b55
Show file tree
Hide file tree
Showing 20 changed files with 452 additions and 11 deletions.
24 changes: 23 additions & 1 deletion backend/core/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import pypandoc
import sentry_sdk
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from logger import get_logger
from middlewares.cors import add_cors_middleware
Expand Down Expand Up @@ -53,3 +54,24 @@ async def http_exception_handler(_, exc):
status_code=exc.status_code,
content={"detail": exc.detail},
)


# log more details about validation errors (422)
def handle_request_validation_error(app: FastAPI):
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
exc_str = f"{exc}".replace("\n", " ").replace(" ", " ")
logger.error(request, exc_str)
content = {
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"message": exc_str,
"data": None,
}
return JSONResponse(
content=content, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
)


handle_request_validation_error(app)
9 changes: 9 additions & 0 deletions backend/core/models/user_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Optional
from uuid import UUID

from pydantic import BaseModel


class UserIdentity(BaseModel):
user_id: UUID
openai_api_key: Optional[str] = None
5 changes: 3 additions & 2 deletions backend/core/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@
from uuid import UUID

from logger import get_logger
from models.settings import common_dependencies
from pydantic import BaseModel

from models.settings import common_dependencies

logger = get_logger(__name__)


# [TODO] Rename the user table and its references to 'user_usage'
class User(BaseModel):
id: UUID
email: Optional[str]
user_openai_api_key: Optional[str] = None
requests_count: int = 0

# [TODO] Rename the user table and its references to 'user_usage'
def create_user(self, date):
"""
Create a new user entry in the database
Expand Down
13 changes: 13 additions & 0 deletions backend/core/repository/user_identity/create_user_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from models.settings import common_dependencies
from models.user_identity import UserIdentity


def create_user_identity(user_identity: UserIdentity) -> UserIdentity:
commons = common_dependencies()
user_identity_dict = user_identity.dict()
user_identity_dict["user_id"] = str(user_identity.user_id)
response = (
commons["supabase"].from_("user_identity").insert(user_identity_dict).execute()
)

return UserIdentity(**response.data[0])
21 changes: 21 additions & 0 deletions backend/core/repository/user_identity/get_user_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from uuid import UUID

from models.settings import common_dependencies
from models.user_identity import UserIdentity
from repository.user_identity.create_user_identity import create_user_identity


def get_user_identity(user_id: UUID) -> UserIdentity:
commons = common_dependencies()
response = (
commons["supabase"]
.from_("user_identity")
.select("*")
.filter("user_id", "eq", user_id)
.execute()
)

if len(response.data) == 0:
return create_user_identity(UserIdentity(user_id=user_id))

return UserIdentity(**response.data[0])
36 changes: 36 additions & 0 deletions backend/core/repository/user_identity/update_user_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Optional
from uuid import UUID

from models.settings import common_dependencies
from models.user_identity import UserIdentity
from pydantic import BaseModel
from repository.user_identity.create_user_identity import (
create_user_identity,
)


class UserIdentityUpdatableProperties(BaseModel):
openai_api_key: Optional[str]


def update_user_identity(
user_id: UUID,
user_identity_updatable_properties: UserIdentityUpdatableProperties,
) -> UserIdentity:
commons = common_dependencies()
response = (
commons["supabase"]
.from_("user_identity")
.update(user_identity_updatable_properties.__dict__)
.filter("user_id", "eq", user_id)
.execute()
)

if len(response.data) == 0:
user_identity = UserIdentity(
user_id=user_id,
openai_api_key=user_identity_updatable_properties.openai_api_key,
)
return create_user_identity(user_identity)

return UserIdentity(**response.data[0])
10 changes: 9 additions & 1 deletion backend/core/routes/upload_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from models.files import File
from models.settings import common_dependencies
from models.users import User
from repository.user_identity.get_user_identity import get_user_identity
from utils.file import convert_bytes, get_file_size
from utils.processors import filter_file

Expand Down Expand Up @@ -59,12 +60,19 @@ async def upload_file(
"type": "error",
}
else:
openai_api_key = request.headers.get("Openai-Api-Key", None)
if openai_api_key is None:
openai_api_key = brain.get_brain_details()["openai_api_key"]

if openai_api_key is None:
openai_api_key = get_user_identity(current_user.id).openai_api_key

message = await filter_file(
commons,
file,
enable_summarization,
brain_id=brain_id,
openai_api_key=request.headers.get("Openai-Api-Key", None),
openai_api_key=openai_api_key,
)

return message
35 changes: 35 additions & 0 deletions backend/core/routes/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
from fastapi import APIRouter, Depends, Request
from models.brains import Brain, get_default_user_brain
from models.settings import BrainRateLimiting
from models.user_identity import UserIdentity
from models.users import User
from repository.user_identity.get_user_identity import get_user_identity
from repository.user_identity.update_user_identity import (
UserIdentityUpdatableProperties,
update_user_identity,
)

user_router = APIRouter()

Expand Down Expand Up @@ -56,3 +62,32 @@ async def get_user_endpoint(
"requests_stats": requests_stats,
"date": date,
}


@user_router.put(
"/user/identity",
dependencies=[Depends(AuthBearer())],
tags=["User"],
)
def update_user_identity_route(
user_identity_updatable_properties: UserIdentityUpdatableProperties,
current_user: User = Depends(get_current_user),
) -> UserIdentity:
"""
Update user identity.
"""
return update_user_identity(current_user.id, user_identity_updatable_properties)


@user_router.get(
"/user/identity",
dependencies=[Depends(AuthBearer())],
tags=["User"],
)
def get_user_identity_route(
current_user: User = Depends(get_current_user),
) -> UserIdentity:
"""
Get user identity.
"""
return get_user_identity(current_user.id)
7 changes: 6 additions & 1 deletion docs/docs/backend/api/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,30 @@ sidebar_position: 1
**Swagger**: https://api.quivr.app/docs

## Overview

This documentation outlines the key points and usage instructions for interacting with the API backend. Please follow the guidelines below to use the backend services effectively.

## Usage Instructions

1. Standalone Backend

- The backend can now be used independently without the frontend application.
- Users can interact with the API endpoints directly using API testing tools like Postman.

2. Generating API Key

- To access the backend services, you need to sign in to the frontend application.
- Once signed in, navigate to the `/config` page to generate a new API key.
- Once signed in, navigate to the `/user` page to generate a new API key.
- The API key will be required to authenticate your requests to the backend.

3. Authenticating Requests

- When making requests to the backend API, include the following header:
- `Authorization: Bearer {api_key}`
- Replace `{api_key}` with the generated API key obtained from the frontend.

4. Future Plans

- The development team has plans to introduce additional features and improvements.
- These include the ability to delete API keys and view the list of active keys.
- The GitHub roadmap will provide more details on upcoming features, including addressing active issues.
Expand Down
7 changes: 6 additions & 1 deletion frontend/app/config/components/ApiKeyConfig/ApiKeyConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import Button from "@/lib/components/ui/Button";
import { useApiKeyConfig } from "./hooks/useApiKeyConfig";

export const ApiKeyConfig = (): JSX.Element => {
const { apiKey, handleCopyClick, handleCreateClick } = useApiKeyConfig();
const {
apiKey,
handleCopyClick,

handleCreateClick,
} = useApiKeyConfig();

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEventTracking } from "@/services/analytics/useEventTracking";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useApiKeyConfig = () => {
const [apiKey, setApiKey] = useState("");
const [openAiApiKey, setOpenAiApiKey] = useState("");
const { track } = useEventTracking();
const { createApiKey } = useAuthApi();

Expand Down Expand Up @@ -38,5 +39,7 @@ export const useApiKeyConfig = () => {
handleCreateClick,
apiKey,
handleCopyClick,
openAiApiKey,
setOpenAiApiKey,
};
};
62 changes: 61 additions & 1 deletion frontend/app/user/components/ApiKeyConfig/ApiKeyConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
/* eslint-disable max-lines */
"use client";

import Button from "@/lib/components/ui/Button";
import { Divider } from "@/lib/components/ui/Divider";
import Field from "@/lib/components/ui/Field";

import { useApiKeyConfig } from "./hooks/useApiKeyConfig";

export const ApiKeyConfig = (): JSX.Element => {
const { apiKey, handleCopyClick, handleCreateClick } = useApiKeyConfig();
const {
apiKey,
handleCopyClick,
handleCreateClick,
openAiApiKey,
setOpenAiApiKey,
changeOpenAiApiKey,
changeOpenAiApiKeyRequestPending,
userIdentity,
removeOpenAiApiKey,
hasOpenAiApiKey,
} = useApiKeyConfig();

return (
<>
Expand Down Expand Up @@ -36,6 +49,53 @@ export const ApiKeyConfig = (): JSX.Element => {
</div>
)}
</div>

<Divider text="OpenAI Key" className="mt-4 mb-4" />
<div className="flex mb-4 justify-center items-center mt-5">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative max-w-md">
<span className="block sm:inline">
Your api key will be saved in our data. We will not use it for any
other purpose. However,{" "}
<strong>
we {"don't"} have not implemented any encryption logic yet
</strong>
</span>
</div>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
void changeOpenAiApiKey();
}}
>
<Field
name="openAiApiKey"
placeholder="Open AI Key"
className="w-full"
value={openAiApiKey ?? ""}
data-testid="open-ai-api-key"
onChange={(e) => setOpenAiApiKey(e.target.value)}
/>
<div className="mt-4 flex flex-row justify-between">
{hasOpenAiApiKey && (
<Button
isLoading={changeOpenAiApiKeyRequestPending}
variant="secondary"
onClick={() => void removeOpenAiApiKey()}
>
Remove Key
</Button>
)}

<Button
data-testid="save-open-ai-api-key"
isLoading={changeOpenAiApiKeyRequestPending}
disabled={openAiApiKey === userIdentity?.openai_api_key}
>
Save Key
</Button>
</div>
</form>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ describe("ApiKeyConfig", () => {
});

it("should render ApiConfig Component", () => {
const { getByText } = render(<ApiKeyConfig />);
const { getByText, getByTestId } = render(<ApiKeyConfig />);
expect(getByText("API Key Config")).toBeDefined();
expect(getByText("OpenAI Key")).toBeDefined();
expect(getByTestId("open-ai-api-key")).toBeDefined();
expect(getByTestId("save-open-ai-api-key")).toBeDefined();
});

it("renders 'Create New Key' button when apiKey is empty", () => {
Expand Down

0 comments on commit 7532b55

Please sign in to comment.