From 85dcafd5fdf1ac524ddf53bc49409cc072d71328 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sat, 31 Aug 2024 15:44:02 +0800 Subject: [PATCH 01/21] Create apikey table --- .../25de3619cb35_add_apikeys_table.py | 38 +++++++++++++++++++ backend/app/models.py | 38 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py diff --git a/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py b/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py new file mode 100644 index 0000000..5f3d7e7 --- /dev/null +++ b/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py @@ -0,0 +1,38 @@ +"""add apikeys table + +Revision ID: 25de3619cb35 +Revises: 20f584dc80d2 +Create Date: 2024-08-31 07:19:42.927401 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '25de3619cb35' +down_revision = '20f584dc80d2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('apikey', + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('hashed_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('short_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('apikey') + # ### end Alembic commands ### diff --git a/backend/app/models.py b/backend/app/models.py index 97a6179..315916d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -156,6 +156,9 @@ class Team(TeamBase, table=True): threads: list["Thread"] = Relationship( back_populates="team", sa_relationship_kwargs={"cascade": "delete"} ) + apikeys: list["ApiKey"] = Relationship( + back_populates="team", sa_relationship_kwargs={"cascade": "delete"} + ) # Properties to return via API, id is always required @@ -490,3 +493,38 @@ class UploadOut(UploadBase): class UploadsOut(SQLModel): data: list[UploadOut] count: int + + +# ==============Api Keys===================== +class ApiKeyBase(SQLModel): + description: str | None = "Default API Key Description" + + +class ApiKeyIn(ApiKeyBase): + pass + + +class ApiKey(ApiKeyBase, table=True): + id: int | None = Field(default=None, primary_key=True) + hashed_key: str + short_key: str + team_id: int | None = Field(default=None, foreign_key="team.id", nullable=False) + team: Team | None = Relationship(back_populates="apikeys") + created_at: datetime | None = Field(default_factory=lambda: datetime.now()) + + +class ApiKeyOut(ApiKeyBase): + id: int | None = Field(default=None, primary_key=True) + key: str + created_at: datetime + + +class ApiKeyOutPublic(ApiKeyBase): + id: int + short_key: str + created_at: datetime + + +class ApiKeysOut(SQLModel): + data: list[ApiKeyOutPublic] + count: int From 24b3b81e42af848997952c4c38de886aa85674d6 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sat, 31 Aug 2024 15:44:22 +0800 Subject: [PATCH 02/21] Add create apikey route --- backend/app/api/main.py | 15 ++++++++- backend/app/api/routes/apikeys.py | 51 +++++++++++++++++++++++++++++++ backend/app/core/security.py | 9 ++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/apikeys.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index f947f63..b88d1a1 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,16 @@ from fastapi import APIRouter -from app.api.routes import login, members, skills, teams, threads, uploads, users, utils +from app.api.routes import ( + apikeys, + login, + members, + skills, + teams, + threads, + uploads, + users, + utils, +) api_router = APIRouter() api_router.include_router(login.router, tags=["login"]) @@ -15,3 +25,6 @@ threads.router, prefix="/teams/{team_id}/threads", tags=["threads"] ) api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"]) +api_router.include_router( + apikeys.router, prefix="/teams/{team_id}/api-keys", tags=["api-keys"] +) diff --git a/backend/app/api/routes/apikeys.py b/backend/app/api/routes/apikeys.py new file mode 100644 index 0000000..360e7cf --- /dev/null +++ b/backend/app/api/routes/apikeys.py @@ -0,0 +1,51 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app.api.deps import CurrentUser, SessionDep +from app.core.security import generate_apikey, generate_short_apikey, get_password_hash +from app.models import ApiKey, ApiKeyIn, ApiKeyOut, Team + +router = APIRouter() + + +@router.post("/", response_model=ApiKeyOut) +def create_api_key( + session: SessionDep, current_user: CurrentUser, team_id: int, apikey_in: ApiKeyIn +) -> Any: + """Create API key for a team.""" + + # Check if the current user is not a superuser + if not current_user.is_superuser: + # Validate the provided team_id and check ownership + team = session.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Team not found.") + if team.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions.") + + # Generate API key and hash it + key = generate_apikey() + hashed_key = get_password_hash(key) + short_key = generate_short_apikey(key) + + # Create the API key object + apikey_data = apikey_in.model_dump() + apikey = ApiKey( + **apikey_data, + team_id=team_id, + hashed_key=hashed_key, + short_key=short_key, + ) + + # Save the new API key to the database + session.add(apikey) + session.commit() + session.refresh(apikey) + + return ApiKeyOut( + id=apikey.id, + description=apikey.description, + key=key, + created_at=apikey.created_at, + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 58da9d9..cf2ae87 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,3 +1,4 @@ +import secrets from datetime import datetime, timedelta from typing import Any @@ -25,3 +26,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def get_password_hash(password: str) -> str: return pwd_context.hash(password) + + +def generate_apikey() -> str: + return secrets.token_urlsafe(32) + + +def generate_short_apikey(key: str) -> str: + return f"{key[:4]}...{key[-4:]}" From 6db8e3bcc5f8fff7b1de43a648fc518573621c39 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sat, 31 Aug 2024 15:53:22 +0800 Subject: [PATCH 03/21] Add route to read api keys --- backend/app/api/routes/apikeys.py | 39 ++++++++++++++++++++++++++++++- backend/app/models.py | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/apikeys.py b/backend/app/api/routes/apikeys.py index 360e7cf..fbb4b5f 100644 --- a/backend/app/api/routes/apikeys.py +++ b/backend/app/api/routes/apikeys.py @@ -1,14 +1,51 @@ from typing import Any from fastapi import APIRouter, HTTPException +from sqlmodel import func, select from app.api.deps import CurrentUser, SessionDep from app.core.security import generate_apikey, generate_short_apikey, get_password_hash -from app.models import ApiKey, ApiKeyIn, ApiKeyOut, Team +from app.models import ApiKey, ApiKeyIn, ApiKeyOut, ApiKeysOutPublic, Team router = APIRouter() +@router.get("/", response_model=ApiKeysOutPublic) +def read_api_keys( + session: SessionDep, + current_user: CurrentUser, + team_id: int, + skip: int = 0, + limit: int = 100, +): + """Read api keys""" + if current_user.is_superuser: + count_statement = select(func.count()).select_from(ApiKey) + count = session.exec(count_statement).one() + statement = ( + select(ApiKey).where(ApiKey.team_id == team_id).offset(skip).limit(limit) + ) + members = session.exec(statement).all() + else: + count_statement = ( + select(func.count()) + .select_from(ApiKey) + .join(Team) + .where(Team.owner_id == current_user.id, ApiKey.team_id == team_id) + ) + count = session.exec(count_statement).one() + statement = ( + select(ApiKey) + .join(Team) + .where(Team.owner_id == current_user.id, ApiKey.team_id == team_id) + .offset(skip) + .limit(limit) + ) + members = session.exec(statement).all() + + return ApiKeysOutPublic(data=members, count=count) + + @router.post("/", response_model=ApiKeyOut) def create_api_key( session: SessionDep, current_user: CurrentUser, team_id: int, apikey_in: ApiKeyIn diff --git a/backend/app/models.py b/backend/app/models.py index 315916d..6e66939 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -525,6 +525,6 @@ class ApiKeyOutPublic(ApiKeyBase): created_at: datetime -class ApiKeysOut(SQLModel): +class ApiKeysOutPublic(SQLModel): data: list[ApiKeyOutPublic] count: int From 7e5eb6d37dabc79f57da5e4b50840381ad69f6e6 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sat, 31 Aug 2024 15:58:34 +0800 Subject: [PATCH 04/21] Add route to delete api key --- backend/app/api/routes/apikeys.py | 40 +++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/backend/app/api/routes/apikeys.py b/backend/app/api/routes/apikeys.py index fbb4b5f..69a62ac 100644 --- a/backend/app/api/routes/apikeys.py +++ b/backend/app/api/routes/apikeys.py @@ -5,7 +5,7 @@ from app.api.deps import CurrentUser, SessionDep from app.core.security import generate_apikey, generate_short_apikey, get_password_hash -from app.models import ApiKey, ApiKeyIn, ApiKeyOut, ApiKeysOutPublic, Team +from app.models import ApiKey, ApiKeyIn, ApiKeyOut, ApiKeysOutPublic, Message, Team router = APIRouter() @@ -17,7 +17,7 @@ def read_api_keys( team_id: int, skip: int = 0, limit: int = 100, -): +) -> Any: """Read api keys""" if current_user.is_superuser: count_statement = select(func.count()).select_from(ApiKey) @@ -25,7 +25,7 @@ def read_api_keys( statement = ( select(ApiKey).where(ApiKey.team_id == team_id).offset(skip).limit(limit) ) - members = session.exec(statement).all() + apikeys = session.exec(statement).all() else: count_statement = ( select(func.count()) @@ -41,9 +41,9 @@ def read_api_keys( .offset(skip) .limit(limit) ) - members = session.exec(statement).all() + apikeys = session.exec(statement).all() - return ApiKeysOutPublic(data=members, count=count) + return ApiKeysOutPublic(data=apikeys, count=count) @router.post("/", response_model=ApiKeyOut) @@ -86,3 +86,33 @@ def create_api_key( key=key, created_at=apikey.created_at, ) + + +@router.delete("/{id}") +def delete_api_key( + session: SessionDep, current_user: CurrentUser, team_id: int, id: int +) -> Any: + """Delete API key for a team.""" + if current_user.is_superuser: + statement = ( + select(ApiKey).join(Team).where(ApiKey.id == id, ApiKey.team_id == team_id) + ) + apikey = session.exec(statement).first() + else: + statement = ( + select(ApiKey) + .join(Team) + .where( + ApiKey.id == id, + ApiKey.team_id == team_id, + Team.owner_id == current_user.id, + ) + ) + apikey = session.exec(statement).first() + + if not apikey: + raise HTTPException(status_code=404, detail="Api key not found") + + session.delete(apikey) + session.commit() + return Message(message="Api key deleted successfully") From bca154f2334785f36957e31d2690f967135e96b5 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 11:33:26 +0800 Subject: [PATCH 05/21] Add route to stream response from team using api key --- backend/app/api/routes/teams.py | 58 +++++++++++++++++++++++++++++++++ backend/app/models.py | 14 +++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 3a8e17f..c40f62d 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -1,16 +1,20 @@ +from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse +from fastapi.security import APIKeyHeader from sqlmodel import col, func, select from app.api.deps import CurrentUser, SessionDep from app.core.graph.build import generator +from app.core.security import verify_password from app.models import ( Member, Message, Team, TeamChat, + TeamChatPublic, TeamCreate, TeamOut, TeamsOut, @@ -20,6 +24,8 @@ router = APIRouter() +header_scheme = APIKeyHeader(name="x-api-key") + async def validate_name_on_create(session: SessionDep, team_in: TeamCreate) -> None: """Validate that team name is unique""" @@ -206,3 +212,55 @@ async def stream( generator(team, members, team_chat.messages, thread_id, team_chat.interrupt), media_type="text/event-stream", ) + + +@router.post("/{id}/stream-public/{thread_id}") +async def public_stream( + session: SessionDep, + id: int, + team_chat: TeamChatPublic, + thread_id: str, + key: str = Depends(header_scheme), +) -> StreamingResponse: + """Stream a response with api key""" + # Check if api key if valid + team = session.get(Team, id) + if not team: + raise HTTPException(status_code=404, detail="Team not found") + verified = False + for apikey in team.apikeys: + if verify_password(key, apikey.hashed_key): + verified = True + break + if not verified: + raise HTTPException(status_code=401, detail="Invalid API key") + + # Check if thread belongs to the team + if not session.get(Thread, thread_id): + # create new thread + thread = Thread( + id=thread_id, + query=team_chat.message.content, + updated_at=datetime.now(), + team_id=id, + ) + session.add(thread) + session.commit() + session.refresh(thread) + else: + thread = session.get(Thread, thread_id) + if thread.team_id != id: + raise HTTPException( + status_code=400, detail="Thread does not belong to the team" + ) + + # Populate the skills and accessible uploads for each member + members = team.members + for member in members: + member.skills = member.skills + member.uploads = member.uploads + + return StreamingResponse( + generator(team, members, [team_chat.message], thread_id, team_chat.interrupt), + media_type="text/event-stream", + ) diff --git a/backend/app/models.py b/backend/app/models.py index 6e66939..5283a1b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,7 +3,7 @@ from typing import Any from uuid import UUID, uuid4 -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from pydantic import Field as PydanticField from sqlalchemy import ( JSON, @@ -144,6 +144,18 @@ class TeamChat(BaseModel): interrupt: Interrupt | None = None +class TeamChatPublic(BaseModel): + message: ChatMessage | None = None + interrupt: Interrupt | None = None + + @model_validator(mode="after") + def check_either_field(cls, values): + message, interrupt = values.message, values.interrupt + if not message and not interrupt: + raise ValueError('Either "message" or "interrupt" must be provided.') + return values + + class Team(TeamBase, table=True): id: int | None = Field(default=None, primary_key=True) name: str = Field(regex=r"^[a-zA-Z0-9_-]{1,64}$", unique=True) From c810e9446913f0cb7041d09681a79a2d3db1b9f4 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 11:37:19 +0800 Subject: [PATCH 06/21] Generate clients for new routes --- frontend/src/client/index.ts | 11 +++ frontend/src/client/models/ApiKeyIn.ts | 8 ++ frontend/src/client/models/ApiKeyOut.ts | 11 +++ frontend/src/client/models/ApiKeyOutPublic.ts | 11 +++ .../src/client/models/ApiKeysOutPublic.ts | 11 +++ frontend/src/client/models/TeamChatPublic.ts | 12 +++ frontend/src/client/schemas/$ApiKeyIn.ts | 16 +++ frontend/src/client/schemas/$ApiKeyOut.ts | 33 +++++++ .../src/client/schemas/$ApiKeyOutPublic.ts | 29 ++++++ .../src/client/schemas/$ApiKeysOutPublic.ts | 19 ++++ .../src/client/schemas/$TeamChatPublic.ts | 24 +++++ .../src/client/services/ApiKeysService.ts | 99 +++++++++++++++++++ frontend/src/client/services/TeamsService.ts | 31 ++++++ 13 files changed, 315 insertions(+) create mode 100644 frontend/src/client/models/ApiKeyIn.ts create mode 100644 frontend/src/client/models/ApiKeyOut.ts create mode 100644 frontend/src/client/models/ApiKeyOutPublic.ts create mode 100644 frontend/src/client/models/ApiKeysOutPublic.ts create mode 100644 frontend/src/client/models/TeamChatPublic.ts create mode 100644 frontend/src/client/schemas/$ApiKeyIn.ts create mode 100644 frontend/src/client/schemas/$ApiKeyOut.ts create mode 100644 frontend/src/client/schemas/$ApiKeyOutPublic.ts create mode 100644 frontend/src/client/schemas/$ApiKeysOutPublic.ts create mode 100644 frontend/src/client/schemas/$TeamChatPublic.ts create mode 100644 frontend/src/client/services/ApiKeysService.ts diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index df5f3e1..28936eb 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -7,6 +7,10 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { ApiKeyIn } from './models/ApiKeyIn'; +export type { ApiKeyOut } from './models/ApiKeyOut'; +export type { ApiKeyOutPublic } from './models/ApiKeyOutPublic'; +export type { ApiKeysOutPublic } from './models/ApiKeysOutPublic'; export type { Body_login_login_access_token } from './models/Body_login_login_access_token'; export type { Body_uploads_create_upload } from './models/Body_uploads_create_upload'; export type { Body_uploads_update_upload } from './models/Body_uploads_update_upload'; @@ -28,6 +32,7 @@ export type { SkillOut } from './models/SkillOut'; export type { SkillsOut } from './models/SkillsOut'; export type { SkillUpdate } from './models/SkillUpdate'; export type { TeamChat } from './models/TeamChat'; +export type { TeamChatPublic } from './models/TeamChatPublic'; export type { TeamCreate } from './models/TeamCreate'; export type { TeamOut } from './models/TeamOut'; export type { TeamsOut } from './models/TeamsOut'; @@ -53,6 +58,10 @@ export type { UserUpdate } from './models/UserUpdate'; export type { UserUpdateMe } from './models/UserUpdateMe'; export type { ValidationError } from './models/ValidationError'; +export { $ApiKeyIn } from './schemas/$ApiKeyIn'; +export { $ApiKeyOut } from './schemas/$ApiKeyOut'; +export { $ApiKeyOutPublic } from './schemas/$ApiKeyOutPublic'; +export { $ApiKeysOutPublic } from './schemas/$ApiKeysOutPublic'; export { $Body_login_login_access_token } from './schemas/$Body_login_login_access_token'; export { $Body_uploads_create_upload } from './schemas/$Body_uploads_create_upload'; export { $Body_uploads_update_upload } from './schemas/$Body_uploads_update_upload'; @@ -74,6 +83,7 @@ export { $SkillOut } from './schemas/$SkillOut'; export { $SkillsOut } from './schemas/$SkillsOut'; export { $SkillUpdate } from './schemas/$SkillUpdate'; export { $TeamChat } from './schemas/$TeamChat'; +export { $TeamChatPublic } from './schemas/$TeamChatPublic'; export { $TeamCreate } from './schemas/$TeamCreate'; export { $TeamOut } from './schemas/$TeamOut'; export { $TeamsOut } from './schemas/$TeamsOut'; @@ -99,6 +109,7 @@ export { $UserUpdate } from './schemas/$UserUpdate'; export { $UserUpdateMe } from './schemas/$UserUpdateMe'; export { $ValidationError } from './schemas/$ValidationError'; +export { ApiKeysService } from './services/ApiKeysService'; export { LoginService } from './services/LoginService'; export { MembersService } from './services/MembersService'; export { SkillsService } from './services/SkillsService'; diff --git a/frontend/src/client/models/ApiKeyIn.ts b/frontend/src/client/models/ApiKeyIn.ts new file mode 100644 index 0000000..62ee928 --- /dev/null +++ b/frontend/src/client/models/ApiKeyIn.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type ApiKeyIn = { + description?: (string | null); +}; diff --git a/frontend/src/client/models/ApiKeyOut.ts b/frontend/src/client/models/ApiKeyOut.ts new file mode 100644 index 0000000..5ef1828 --- /dev/null +++ b/frontend/src/client/models/ApiKeyOut.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type ApiKeyOut = { + description?: (string | null); + id?: (number | null); + key: string; + created_at: string; +}; diff --git a/frontend/src/client/models/ApiKeyOutPublic.ts b/frontend/src/client/models/ApiKeyOutPublic.ts new file mode 100644 index 0000000..79f8b62 --- /dev/null +++ b/frontend/src/client/models/ApiKeyOutPublic.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type ApiKeyOutPublic = { + description?: (string | null); + id: number; + short_key: string; + created_at: string; +}; diff --git a/frontend/src/client/models/ApiKeysOutPublic.ts b/frontend/src/client/models/ApiKeysOutPublic.ts new file mode 100644 index 0000000..505cd3f --- /dev/null +++ b/frontend/src/client/models/ApiKeysOutPublic.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ApiKeyOutPublic } from './ApiKeyOutPublic'; + +export type ApiKeysOutPublic = { + data: Array; + count: number; +}; diff --git a/frontend/src/client/models/TeamChatPublic.ts b/frontend/src/client/models/TeamChatPublic.ts new file mode 100644 index 0000000..64cf8bb --- /dev/null +++ b/frontend/src/client/models/TeamChatPublic.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ChatMessage } from './ChatMessage'; +import type { Interrupt } from './Interrupt'; + +export type TeamChatPublic = { + message?: (ChatMessage | null); + interrupt?: (Interrupt | null); +}; diff --git a/frontend/src/client/schemas/$ApiKeyIn.ts b/frontend/src/client/schemas/$ApiKeyIn.ts new file mode 100644 index 0000000..3d12cfc --- /dev/null +++ b/frontend/src/client/schemas/$ApiKeyIn.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ApiKeyIn = { + properties: { + description: { + type: 'any-of', + contains: [{ + type: 'string', + }, { + type: 'null', + }], + }, + }, +} as const; diff --git a/frontend/src/client/schemas/$ApiKeyOut.ts b/frontend/src/client/schemas/$ApiKeyOut.ts new file mode 100644 index 0000000..1aad631 --- /dev/null +++ b/frontend/src/client/schemas/$ApiKeyOut.ts @@ -0,0 +1,33 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ApiKeyOut = { + properties: { + description: { + type: 'any-of', + contains: [{ + type: 'string', + }, { + type: 'null', + }], + }, + id: { + type: 'any-of', + contains: [{ + type: 'number', + }, { + type: 'null', + }], + }, + key: { + type: 'string', + isRequired: true, + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/frontend/src/client/schemas/$ApiKeyOutPublic.ts b/frontend/src/client/schemas/$ApiKeyOutPublic.ts new file mode 100644 index 0000000..a09dfc5 --- /dev/null +++ b/frontend/src/client/schemas/$ApiKeyOutPublic.ts @@ -0,0 +1,29 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ApiKeyOutPublic = { + properties: { + description: { + type: 'any-of', + contains: [{ + type: 'string', + }, { + type: 'null', + }], + }, + id: { + type: 'number', + isRequired: true, + }, + short_key: { + type: 'string', + isRequired: true, + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/frontend/src/client/schemas/$ApiKeysOutPublic.ts b/frontend/src/client/schemas/$ApiKeysOutPublic.ts new file mode 100644 index 0000000..965890f --- /dev/null +++ b/frontend/src/client/schemas/$ApiKeysOutPublic.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ApiKeysOutPublic = { + properties: { + data: { + type: 'array', + contains: { + type: 'ApiKeyOutPublic', + }, + isRequired: true, + }, + count: { + type: 'number', + isRequired: true, + }, + }, +} as const; diff --git a/frontend/src/client/schemas/$TeamChatPublic.ts b/frontend/src/client/schemas/$TeamChatPublic.ts new file mode 100644 index 0000000..b19f50d --- /dev/null +++ b/frontend/src/client/schemas/$TeamChatPublic.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $TeamChatPublic = { + properties: { + message: { + type: 'any-of', + contains: [{ + type: 'ChatMessage', + }, { + type: 'null', + }], + }, + interrupt: { + type: 'any-of', + contains: [{ + type: 'Interrupt', + }, { + type: 'null', + }], + }, + }, +} as const; diff --git a/frontend/src/client/services/ApiKeysService.ts b/frontend/src/client/services/ApiKeysService.ts new file mode 100644 index 0000000..41a259e --- /dev/null +++ b/frontend/src/client/services/ApiKeysService.ts @@ -0,0 +1,99 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiKeyIn } from '../models/ApiKeyIn'; +import type { ApiKeyOut } from '../models/ApiKeyOut'; +import type { ApiKeysOutPublic } from '../models/ApiKeysOutPublic'; + +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; + +export class ApiKeysService { + + /** + * Read Api Keys + * Read api keys + * @returns ApiKeysOutPublic Successful Response + * @throws ApiError + */ + public static readApiKeys({ + teamId, + skip, + limit = 100, + }: { + teamId: number, + skip?: number, + limit?: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/teams/{team_id}/api-keys/', + path: { + 'team_id': teamId, + }, + query: { + 'skip': skip, + 'limit': limit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Create Api Key + * Create API key for a team. + * @returns ApiKeyOut Successful Response + * @throws ApiError + */ + public static createApiKey({ + teamId, + requestBody, + }: { + teamId: number, + requestBody: ApiKeyIn, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/teams/{team_id}/api-keys/', + path: { + 'team_id': teamId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Delete Api Key + * Delete API key for a team. + * @returns any Successful Response + * @throws ApiError + */ + public static deleteApiKey({ + teamId, + id, + }: { + teamId: number, + id: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/teams/{team_id}/api-keys/{id}', + path: { + 'team_id': teamId, + 'id': id, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} diff --git a/frontend/src/client/services/TeamsService.ts b/frontend/src/client/services/TeamsService.ts index c3a2654..f48a44f 100644 --- a/frontend/src/client/services/TeamsService.ts +++ b/frontend/src/client/services/TeamsService.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { TeamChat } from '../models/TeamChat'; +import type { TeamChatPublic } from '../models/TeamChatPublic'; import type { TeamCreate } from '../models/TeamCreate'; import type { TeamOut } from '../models/TeamOut'; import type { TeamsOut } from '../models/TeamsOut'; @@ -165,4 +166,34 @@ export class TeamsService { }); } + /** + * Public Stream + * Stream a response with api key + * @returns any Successful Response + * @throws ApiError + */ + public static publicStream({ + id, + threadId, + requestBody, + }: { + id: number, + threadId: string, + requestBody: TeamChatPublic, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/teams/{id}/stream-public/{thread_id}', + path: { + 'id': id, + 'thread_id': threadId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + } From a1c883890c8dd9d645995631c64874d4f7bd0a2d Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 15:17:31 +0800 Subject: [PATCH 07/21] Change ApiKeyIn model name to ApiKeyCreate for consistency --- backend/app/api/routes/apikeys.py | 7 +++++-- backend/app/models.py | 2 +- frontend/src/client/index.ts | 4 ++-- .../src/client/models/{ApiKeyIn.ts => ApiKeyCreate.ts} | 2 +- .../src/client/schemas/{$ApiKeyIn.ts => $ApiKeyCreate.ts} | 2 +- frontend/src/client/services/ApiKeysService.ts | 4 ++-- 6 files changed, 12 insertions(+), 9 deletions(-) rename frontend/src/client/models/{ApiKeyIn.ts => ApiKeyCreate.ts} (85%) rename frontend/src/client/schemas/{$ApiKeyIn.ts => $ApiKeyCreate.ts} (91%) diff --git a/backend/app/api/routes/apikeys.py b/backend/app/api/routes/apikeys.py index 69a62ac..a2cbbd4 100644 --- a/backend/app/api/routes/apikeys.py +++ b/backend/app/api/routes/apikeys.py @@ -5,7 +5,7 @@ from app.api.deps import CurrentUser, SessionDep from app.core.security import generate_apikey, generate_short_apikey, get_password_hash -from app.models import ApiKey, ApiKeyIn, ApiKeyOut, ApiKeysOutPublic, Message, Team +from app.models import ApiKey, ApiKeyCreate, ApiKeyOut, ApiKeysOutPublic, Message, Team router = APIRouter() @@ -48,7 +48,10 @@ def read_api_keys( @router.post("/", response_model=ApiKeyOut) def create_api_key( - session: SessionDep, current_user: CurrentUser, team_id: int, apikey_in: ApiKeyIn + session: SessionDep, + current_user: CurrentUser, + team_id: int, + apikey_in: ApiKeyCreate, ) -> Any: """Create API key for a team.""" diff --git a/backend/app/models.py b/backend/app/models.py index 5283a1b..8e75b8b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -512,7 +512,7 @@ class ApiKeyBase(SQLModel): description: str | None = "Default API Key Description" -class ApiKeyIn(ApiKeyBase): +class ApiKeyCreate(ApiKeyBase): pass diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index 28936eb..7a062be 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -7,7 +7,7 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; -export type { ApiKeyIn } from './models/ApiKeyIn'; +export type { ApiKeyCreate } from './models/ApiKeyCreate'; export type { ApiKeyOut } from './models/ApiKeyOut'; export type { ApiKeyOutPublic } from './models/ApiKeyOutPublic'; export type { ApiKeysOutPublic } from './models/ApiKeysOutPublic'; @@ -58,7 +58,7 @@ export type { UserUpdate } from './models/UserUpdate'; export type { UserUpdateMe } from './models/UserUpdateMe'; export type { ValidationError } from './models/ValidationError'; -export { $ApiKeyIn } from './schemas/$ApiKeyIn'; +export { $ApiKeyCreate } from './schemas/$ApiKeyCreate'; export { $ApiKeyOut } from './schemas/$ApiKeyOut'; export { $ApiKeyOutPublic } from './schemas/$ApiKeyOutPublic'; export { $ApiKeysOutPublic } from './schemas/$ApiKeysOutPublic'; diff --git a/frontend/src/client/models/ApiKeyIn.ts b/frontend/src/client/models/ApiKeyCreate.ts similarity index 85% rename from frontend/src/client/models/ApiKeyIn.ts rename to frontend/src/client/models/ApiKeyCreate.ts index 62ee928..9db2948 100644 --- a/frontend/src/client/models/ApiKeyIn.ts +++ b/frontend/src/client/models/ApiKeyCreate.ts @@ -3,6 +3,6 @@ /* tslint:disable */ /* eslint-disable */ -export type ApiKeyIn = { +export type ApiKeyCreate = { description?: (string | null); }; diff --git a/frontend/src/client/schemas/$ApiKeyIn.ts b/frontend/src/client/schemas/$ApiKeyCreate.ts similarity index 91% rename from frontend/src/client/schemas/$ApiKeyIn.ts rename to frontend/src/client/schemas/$ApiKeyCreate.ts index 3d12cfc..98548e9 100644 --- a/frontend/src/client/schemas/$ApiKeyIn.ts +++ b/frontend/src/client/schemas/$ApiKeyCreate.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export const $ApiKeyIn = { +export const $ApiKeyCreate = { properties: { description: { type: 'any-of', diff --git a/frontend/src/client/services/ApiKeysService.ts b/frontend/src/client/services/ApiKeysService.ts index 41a259e..f15d267 100644 --- a/frontend/src/client/services/ApiKeysService.ts +++ b/frontend/src/client/services/ApiKeysService.ts @@ -2,7 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { ApiKeyIn } from '../models/ApiKeyIn'; +import type { ApiKeyCreate } from '../models/ApiKeyCreate'; import type { ApiKeyOut } from '../models/ApiKeyOut'; import type { ApiKeysOutPublic } from '../models/ApiKeysOutPublic'; @@ -54,7 +54,7 @@ export class ApiKeysService { requestBody, }: { teamId: number, - requestBody: ApiKeyIn, + requestBody: ApiKeyCreate, }): CancelablePromise { return __request(OpenAPI, { method: 'POST', From 4adbd38995ea73718a5b9df68c451b52c435fc7e Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 16:03:54 +0800 Subject: [PATCH 08/21] Fix created_at col to have timezone and description col to be nullable=true for apikey table --- .../app/alembic/versions/25de3619cb35_add_apikeys_table.py | 4 ++-- backend/app/models.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py b/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py index 5f3d7e7..16696e8 100644 --- a/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py +++ b/backend/app/alembic/versions/25de3619cb35_add_apikeys_table.py @@ -20,12 +20,12 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('apikey', - sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('id', sa.Integer(), nullable=False), sa.Column('hashed_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('short_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('team_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), sa.PrimaryKeyConstraint('id') ) diff --git a/backend/app/models.py b/backend/app/models.py index 8e75b8b..75c9f0a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Any from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo from pydantic import BaseModel, model_validator from pydantic import Field as PydanticField @@ -522,7 +523,9 @@ class ApiKey(ApiKeyBase, table=True): short_key: str team_id: int | None = Field(default=None, foreign_key="team.id", nullable=False) team: Team | None = Relationship(back_populates="apikeys") - created_at: datetime | None = Field(default_factory=lambda: datetime.now()) + created_at: datetime | None = Field( + default_factory=lambda: datetime.now(ZoneInfo("UTC")) + ) class ApiKeyOut(ApiKeyBase): From 667d30ac4b17a5e656511cb4296d9f64f38f10ac Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 16:04:43 +0800 Subject: [PATCH 09/21] Fix create_api_key route so if description is whitespace only or empty string it will be None --- backend/app/api/routes/apikeys.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/apikeys.py b/backend/app/api/routes/apikeys.py index a2cbbd4..78913df 100644 --- a/backend/app/api/routes/apikeys.py +++ b/backend/app/api/routes/apikeys.py @@ -70,12 +70,13 @@ def create_api_key( short_key = generate_short_apikey(key) # Create the API key object - apikey_data = apikey_in.model_dump() apikey = ApiKey( - **apikey_data, team_id=team_id, hashed_key=hashed_key, short_key=short_key, + description=None + if not apikey_in.description.strip() + else apikey_in.description, ) # Save the new API key to the database From 88e4c3a132977c9817808b8e81708215156652b9 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 18:27:39 +0800 Subject: [PATCH 10/21] Add component consisting of readonly input + copy button --- frontend/src/components/Common/CopyInput.tsx | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 frontend/src/components/Common/CopyInput.tsx diff --git a/frontend/src/components/Common/CopyInput.tsx b/frontend/src/components/Common/CopyInput.tsx new file mode 100644 index 0000000..691da15 --- /dev/null +++ b/frontend/src/components/Common/CopyInput.tsx @@ -0,0 +1,37 @@ +import { InputGroup, Input, InputRightElement, IconButton } from "@chakra-ui/react"; +import { useState } from "react"; +import { FiCheck, FiCopy } from "react-icons/fi"; + +interface CopyInputProps { + value: string; +} + +export const CopyInput = ({ value }: CopyInputProps) => { + const [copied, setCopied] = useState(false); + + const onClickHandler = () => { + setCopied(true); + navigator.clipboard.writeText(value); + + // Revert back to the copy icon after 2 seconds + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + + : } + size="sm" + onClick={onClickHandler} + /> + + + ); +}; + +export default CopyInput; From c43bad812bbfca5d5f7579815a09ac7860cfba75 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 18:31:03 +0800 Subject: [PATCH 11/21] Create add api key modal component --- frontend/src/components/Teams/AddApiKey.tsx | 129 ++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 frontend/src/components/Teams/AddApiKey.tsx diff --git a/frontend/src/components/Teams/AddApiKey.tsx b/frontend/src/components/Teams/AddApiKey.tsx new file mode 100644 index 0000000..3c481e7 --- /dev/null +++ b/frontend/src/components/Teams/AddApiKey.tsx @@ -0,0 +1,129 @@ +import { + Button, + FormControl, + FormErrorMessage, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react" +import { type SubmitHandler, useForm } from "react-hook-form" +import { useMutation, useQueryClient } from "react-query" + +import { type ApiError, type ApiKeyCreate, ApiKeysService } from "../../client" +import useCustomToast from "../../hooks/useCustomToast" +import { useState } from "react" +import CopyInput from "../Common/CopyInput" + +interface AddApiKeyProps { + teamId: string, + isOpen: boolean + onClose: () => void +} + +const AddApiKey = ({ teamId, isOpen, onClose }: AddApiKeyProps) => { + const queryClient = useQueryClient() + const showToast = useCustomToast() + const [apiKey, setApiKey] = useState(null); + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting, isValid }, + } = useForm({ + mode: "onBlur", + criteriaMode: "all", + defaultValues: {}, + }) + + const addApiKey = async (data: ApiKeyCreate) => { + return await ApiKeysService.createApiKey({ requestBody: data, teamId: Number.parseInt(teamId) }) + } + + const mutation = useMutation(addApiKey, { + onSuccess: (data) => { + showToast("Success!", "Team created successfully.", "success") + setApiKey(data.key) + reset() + }, + onError: (err: ApiError) => { + const errDetail = err.body?.detail + showToast("Something went wrong.", `${errDetail}`, "error") + }, + onSettled: () => { + queryClient.invalidateQueries("apikeys") + }, + }) + + const onSubmit: SubmitHandler = (data) => { + mutation.mutate(data) + } + + const closeModalHandler = () => { + onClose() + setApiKey(null) + } + + return ( + <> + + + {!apiKey ? + Create an API key + + + + Description + + {errors.description && ( + {errors.description.message} + )} + + + + + + + : + Copy your new API key + + + + + + + + } + + + ) +} + +export default AddApiKey From c081f77dd156113e9ecb183678f1c22c1c8018ea Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Sun, 1 Sep 2024 22:40:21 +0800 Subject: [PATCH 12/21] Dont allow close on overlay click and remove close button from AppApiKey modal --- frontend/src/components/Teams/AddApiKey.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Teams/AddApiKey.tsx b/frontend/src/components/Teams/AddApiKey.tsx index 3c481e7..d3707b8 100644 --- a/frontend/src/components/Teams/AddApiKey.tsx +++ b/frontend/src/components/Teams/AddApiKey.tsx @@ -47,7 +47,7 @@ const AddApiKey = ({ teamId, isOpen, onClose }: AddApiKeyProps) => { const mutation = useMutation(addApiKey, { onSuccess: (data) => { - showToast("Success!", "Team created successfully.", "success") + showToast("Success!", "API key created successfully.", "success") setApiKey(data.key) reset() }, @@ -76,6 +76,7 @@ const AddApiKey = ({ teamId, isOpen, onClose }: AddApiKeyProps) => { onClose={onClose} size={{ base: "sm", md: "md" }} isCentered + closeOnOverlayClick={false} > {!apiKey ? @@ -108,13 +109,11 @@ const AddApiKey = ({ teamId, isOpen, onClose }: AddApiKeyProps) => { : Copy your new API key - + + + You can only access an API key when you first create it. If you lost + one, you will need to create a new one. Your API keys are listed below. + + + + + + + + + + + + + {!isLoading && apikeys?.data.map((apikey) => ( + + + + + + + ))} + +
DescriptionAPI KeyCreatedActions
+ {apikey.description} + {apikey.short_key}{new Date(apikey.created_at).toLocaleString()} + } + onClick={(e) => onDeleteHandler(e, apikey.id)} + /> +
+
+ + ); +}; + +export default ConfigureTeam; diff --git a/frontend/src/routes/_layout/teams.$teamId.tsx b/frontend/src/routes/_layout/teams.$teamId.tsx index fcc2c7f..bb058d9 100644 --- a/frontend/src/routes/_layout/teams.$teamId.tsx +++ b/frontend/src/routes/_layout/teams.$teamId.tsx @@ -21,6 +21,7 @@ import Flow from "../../components/ReactFlow/Flow" import ChatTeam from "../../components/Teams/ChatTeam" import ViewThreads from "../../components/Teams/ViewThreads" import { useState } from "react" +import ConfigureTeam from "../../components/Teams/ConfigureTeam" type SearchSchema = { threadId?: string @@ -96,6 +97,7 @@ function Team() { Build Chat Threads + Configure @@ -107,6 +109,9 @@ function Team() { + + + From 30f665cb250ec5103b8ea6a2d3aff9fe239721a3 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Mon, 2 Sep 2024 23:57:02 +0800 Subject: [PATCH 15/21] Create dep for checking if api key belong to team. Rename 'id' param to 'team_id' for public_stream so its consistent with other routes --- backend/app/api/deps.py | 29 +++++++++++++++++++++++++-- backend/app/api/routes/teams.py | 35 ++++++++++++--------------------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 2edbe9b..3b026a8 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -3,7 +3,7 @@ import jwt from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session @@ -11,7 +11,7 @@ from app.core import security from app.core.config import settings from app.core.db import engine -from app.models import TokenPayload, User +from app.models import Team, TokenPayload, User reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" @@ -55,3 +55,28 @@ def get_current_active_superuser(current_user: CurrentUser) -> User: status_code=400, detail="The user doesn't have enough privileges" ) return current_user + + +header_scheme = APIKeyHeader(name="x-api-key") + + +def get_current_team_from_key( + session: SessionDep, + team_id: int, + key: str = Depends(header_scheme), +) -> Team: + """Return team if apikey belongs to it""" + team = session.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Team not found") + verified = False + for apikey in team.apikeys: + if security.verify_password(key, apikey.hashed_key): + verified = True + break + if not verified: + raise HTTPException(status_code=401, detail="Invalid API key") + return team + + +CurrentTeam = Annotated[Team, Depends(get_current_team_from_key)] diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 718e69c..8c36e5d 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -6,9 +6,12 @@ from fastapi.security import APIKeyHeader from sqlmodel import col, func, select -from app.api.deps import CurrentUser, SessionDep +from app.api.deps import ( + CurrentTeam, + CurrentUser, + SessionDep, +) from app.core.graph.build import generator -from app.core.security import verify_password from app.models import ( Member, Message, @@ -214,13 +217,13 @@ async def stream( ) -@router.post("/{id}/stream-public/{thread_id}") +@router.post("/{team_id}/stream-public/{thread_id}") async def public_stream( session: SessionDep, - id: int, + team_id: int, team_chat: TeamChatPublic, thread_id: str, - key: str = Depends(header_scheme), + team: CurrentTeam, ) -> StreamingResponse: """ Stream a response from a team using a given message or an interrupt decision. Requires an API key for authentication. @@ -228,7 +231,7 @@ async def public_stream( This endpoint allows streaming responses from a team based on a provided message or interrupt details. The request must include an API key for authorization. Parameters: - - `id` (int): The ID of the team to which the message is being sent. Must be a valid team ID. + - `team_id` (int): The ID of the team to which the message is being sent. Must be a valid team ID. - `thread_id` (str): The ID of the thread where the message will be posted. If the thread ID does not exist, a new thread will be created. Request Body (JSON): @@ -246,33 +249,21 @@ async def public_stream( Responses: - `200 OK`: Returns a streaming response in `text/event-stream` format containing the team's response. """ - # Check if api key if valid - team = session.get(Team, id) - if not team: - raise HTTPException(status_code=404, detail="Team not found") - verified = False - for apikey in team.apikeys: - if verify_password(key, apikey.hashed_key): - verified = True - break - if not verified: - raise HTTPException(status_code=401, detail="Invalid API key") - # Check if thread belongs to the team - if not session.get(Thread, thread_id): + thread = session.get(Thread, thread_id) + if not thread: # create new thread thread = Thread( id=thread_id, query=team_chat.message.content, updated_at=datetime.now(), - team_id=id, + team_id=team_id, ) session.add(thread) session.commit() session.refresh(thread) else: - thread = session.get(Thread, thread_id) - if thread.team_id != id: + if thread.team_id != team_id: raise HTTPException( status_code=400, detail="Thread does not belong to the team" ) From ca92c4bdf49b0a76c4b3ded37fefc7390066cacc Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Mon, 2 Sep 2024 23:57:44 +0800 Subject: [PATCH 16/21] Create public read thread route --- backend/app/api/routes/threads.py | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/threads.py b/backend/app/api/routes/threads.py index 5d764d8..a668b01 100644 --- a/backend/app/api/routes/threads.py +++ b/backend/app/api/routes/threads.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, HTTPException from sqlmodel import col, func, select -from app.api.deps import CurrentUser, SessionDep +from app.api.deps import CurrentTeam, CurrentUser, SessionDep from app.core.graph.checkpoint.utils import ( convert_checkpoint_tuple_to_messages, get_checkpoint_tuples, @@ -113,6 +113,42 @@ async def read_thread( ) +@router.get("/public/{thread_id}", response_model=ThreadRead) +async def read_thread_public( + session: SessionDep, + thread_id: UUID, + team: CurrentTeam, +) -> Any: + """ + Get thread and its last checkpoint by ID + """ + statement = ( + select(Thread) + .join(Team) + .where( + Thread.id == thread_id, + Thread.team_id == team.id, + ) + ) + thread = session.exec(statement).first() + + if not thread: + raise HTTPException(status_code=404, detail="Thread not found") + + checkpoint_tuple = await get_checkpoint_tuples(str(thread.id)) + if checkpoint_tuple: + messages = convert_checkpoint_tuple_to_messages(checkpoint_tuple) + else: + messages = [] + + return ThreadRead( + id=thread.id, + query=thread.query, + messages=messages, + updated_at=thread.updated_at, + ) + + @router.post("/", response_model=ThreadOut) def create_thread( *, From 84d322f045e3c768871e7321f6ca5fd4494c4707 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Mon, 2 Sep 2024 23:58:14 +0800 Subject: [PATCH 17/21] Fix EditMember modal so it does not crash it an invalid modelProvider is specified --- frontend/src/components/Members/EditMember.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Members/EditMember.tsx b/frontend/src/components/Members/EditMember.tsx index 68565a0..c599e49 100644 --- a/frontend/src/components/Members/EditMember.tsx +++ b/frontend/src/components/Members/EditMember.tsx @@ -226,7 +226,7 @@ export function EditMember({ : [] const modelProvider = watch("provider") as ModelProvider - const modelOptions: ModelOption[] = AVAILABLE_MODELS[modelProvider].map( + const modelOptions: ModelOption[] = (AVAILABLE_MODELS[modelProvider] ?? []).map( (model) => ({ label: model, value: model, From 9ad0a32aa72811419676d57f295a0f9e21002d4e Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Tue, 3 Sep 2024 00:09:30 +0800 Subject: [PATCH 18/21] Update readme to mention public api endpoints --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62d773f..2bb9b12 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - [Contribution](#contribution) - [Release Notes](#release-notes) - [License](#license) - + > [!WARNING] > This project is currently under heavy development. Please be aware that significant changes may occur. @@ -66,6 +66,7 @@ and many many more! - **Retrieval Augmented Generation**: Enable your agents to reason with your internal knowledge base. - **Human-In-The-Loop**: Enable human approval before tool calling. - **Open Source Models**: Use open-source LLM models such as llama, Gemma and Phi. +- **Integrate Tribe with external application**: Use Tribe’s public API endpoints to interact with your teams. - **Easy Deployment**: Deploy Tribe effortlessly using Docker. - **Multi-Tenancy**: Manage and support multiple users and teams. @@ -106,7 +107,7 @@ Copy the content and use that as password / secret key. And run that again to ge #### Sequential workflows -In a sequential workflow, your agents are arranged in an orderly sequence and execute tasks one after another. Each task can be dependent on the previous task. This is useful if you want to tasks to be completed one after another in a deterministic sequence. +In a sequential workflow, your agents are arranged in an orderly sequence and execute tasks one after another. Each task can be dependent on the previous task. This is useful if you want to tasks to be completed one after another in a deterministic sequence. Use this if: - Your project has clear, step-by-step tasks. From ae73666e9b4a6d981fa643ea6d2049ca092596f7 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Tue, 3 Sep 2024 00:21:06 +0800 Subject: [PATCH 19/21] Fix mypy issues --- backend/app/api/routes/apikeys.py | 5 ++--- backend/app/api/routes/teams.py | 6 ++++-- backend/app/models.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/app/api/routes/apikeys.py b/backend/app/api/routes/apikeys.py index 78913df..5422196 100644 --- a/backend/app/api/routes/apikeys.py +++ b/backend/app/api/routes/apikeys.py @@ -70,13 +70,12 @@ def create_api_key( short_key = generate_short_apikey(key) # Create the API key object + description = apikey_in.description apikey = ApiKey( team_id=team_id, hashed_key=hashed_key, short_key=short_key, - description=None - if not apikey_in.description.strip() - else apikey_in.description, + description=None if not description or not description.strip() else description, ) # Save the new API key to the database diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 8c36e5d..332fa9a 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -251,11 +251,12 @@ async def public_stream( """ # Check if thread belongs to the team thread = session.get(Thread, thread_id) + message_content = team_chat.message.content if team_chat.message else "" if not thread: # create new thread thread = Thread( id=thread_id, - query=team_chat.message.content, + query=message_content, updated_at=datetime.now(), team_id=team_id, ) @@ -274,7 +275,8 @@ async def public_stream( member.skills = member.skills member.uploads = member.uploads + messages = [team_chat.message] if team_chat.message else [] return StreamingResponse( - generator(team, members, [team_chat.message], thread_id, team_chat.interrupt), + generator(team, members, messages, thread_id, team_chat.interrupt), media_type="text/event-stream", ) diff --git a/backend/app/models.py b/backend/app/models.py index 75c9f0a..ad58a5a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -150,7 +150,7 @@ class TeamChatPublic(BaseModel): interrupt: Interrupt | None = None @model_validator(mode="after") - def check_either_field(cls, values): + def check_either_field(cls: Any, values: Any) -> Any: message, interrupt = values.message, values.interrupt if not message and not interrupt: raise ValueError('Either "message" or "interrupt" must be provided.') From 91794c89406e48dc713c4971aa862dafe62b2445 Mon Sep 17 00:00:00 2001 From: StreetLamb Date: Tue, 3 Sep 2024 00:32:47 +0800 Subject: [PATCH 20/21] Fix import error --- .../src/components/Teams/ConfigureTeam.tsx | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/Teams/ConfigureTeam.tsx b/frontend/src/components/Teams/ConfigureTeam.tsx index ee279a8..3d50303 100644 --- a/frontend/src/components/Teams/ConfigureTeam.tsx +++ b/frontend/src/components/Teams/ConfigureTeam.tsx @@ -13,58 +13,58 @@ import { Tr, VStack, useDisclosure, -} from "@chakra-ui/react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { ApiError, ApiKeysService } from "../../client"; -import { MdOutlineVpnKey } from "react-icons/md"; -import AddApiKey from "./AddApikey"; -import { DeleteIcon } from "@chakra-ui/icons"; -import useCustomToast from "../../hooks/useCustomToast"; +} from "@chakra-ui/react" +import { useMutation, useQuery, useQueryClient } from "react-query" +import { type ApiError, ApiKeysService } from "../../client" +import { MdOutlineVpnKey } from "react-icons/md" +import AddApiKey from "./AddApiKey" +import { DeleteIcon } from "@chakra-ui/icons" +import useCustomToast from "../../hooks/useCustomToast" interface ConfigureTeamProps { - teamId: string; + teamId: string } export const ConfigureTeam = ({ teamId }: ConfigureTeamProps) => { - const queryClient = useQueryClient(); - const addApiKeyModal = useDisclosure(); - const showToast = useCustomToast(); + const queryClient = useQueryClient() + const addApiKeyModal = useDisclosure() + const showToast = useCustomToast() const { data: apikeys, isLoading, isError, error, } = useQuery("apikeys", () => - ApiKeysService.readApiKeys({ teamId: Number.parseInt(teamId) }) - ); + ApiKeysService.readApiKeys({ teamId: Number.parseInt(teamId) }), + ) const deleteApiKey = async (apiKeyId: number) => { await ApiKeysService.deleteApiKey({ teamId: Number.parseInt(teamId), id: apiKeyId, - }); - }; + }) + } const deleteApiKeyMutation = useMutation(deleteApiKey, { onError: (err: ApiError) => { - const errDetail = err.body?.detail; - showToast("Unable to delete thread.", `${errDetail}`, "error"); + const errDetail = err.body?.detail + showToast("Unable to delete thread.", `${errDetail}`, "error") }, onSettled: () => { - queryClient.invalidateQueries("apikeys"); + queryClient.invalidateQueries("apikeys") }, onSuccess: () => { showToast("Success!", "API key deleted successfully.", "success") }, - }); + }) const onDeleteHandler = ( e: React.MouseEvent, - apiKeyId: number + apiKeyId: number, ) => { - e.stopPropagation(); - deleteApiKeyMutation.mutate(apiKeyId); - }; + e.stopPropagation() + deleteApiKeyMutation.mutate(apiKeyId) + } if (isError) { const errDetail = (error as ApiError).body?.detail @@ -76,7 +76,17 @@ export const ConfigureTeam = ({ teamId }: ConfigureTeamProps) => { API keys API keys are used for authentication when interacting with your teams - through HTTP request. Learn how to make requests from the {API docs}. + through HTTP request. Learn how to make requests from the{" "} + { + + API docs + + } + .