From 35e19befea8c4eedcbf082f009a5f84791af3ddb Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Fri, 19 Apr 2024 17:30:20 -0400 Subject: [PATCH 01/73] moving over assistants stuff --- .gitignore | 2 + pyproject.toml | 2 +- src/leapfrogai_api/.env.example | 2 + .../backends/openai/__init__.py | 3 - src/leapfrogai_api/backends/openai/routes.py | 4 +- src/leapfrogai_api/data/__init__.py | 0 src/leapfrogai_api/data/supabase_client.py | 138 +++++++++++++++ src/leapfrogai_api/main.py | 5 +- src/leapfrogai_api/pyproject.toml | 4 +- src/leapfrogai_api/routers/__init__.py | 0 src/leapfrogai_api/routers/assistants.py | 124 ++++++++++++++ .../routers/assistants_types.py | 28 +++ src/leapfrogai_api/utils/openai_util.py | 66 +++++++ supabase/.gitignore | 4 + supabase/config.toml | 161 ++++++++++++++++++ .../20240419164109_init-assistant.sql | 17 ++ supabase/seed.sql | 0 .../pytest/leapfrogai_api/test_assistants.py | 80 +++++++++ 18 files changed, 632 insertions(+), 8 deletions(-) create mode 100644 src/leapfrogai_api/.env.example create mode 100644 src/leapfrogai_api/data/__init__.py create mode 100644 src/leapfrogai_api/data/supabase_client.py create mode 100644 src/leapfrogai_api/routers/__init__.py create mode 100644 src/leapfrogai_api/routers/assistants.py create mode 100644 src/leapfrogai_api/routers/assistants_types.py create mode 100644 src/leapfrogai_api/utils/openai_util.py create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20240419164109_init-assistant.sql create mode 100644 supabase/seed.sql create mode 100644 tests/pytest/leapfrogai_api/test_assistants.py diff --git a/.gitignore b/.gitignore index e172d8c8a..b201b237d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,10 +14,12 @@ zarf-sbom/ /values.yaml .pytest_cache *egg-info +egg-info/ build/ *.whl .model/ *.gguf +.env # local model and tokenizer files *.bin diff --git a/pyproject.toml b/pyproject.toml index c133c59f5..e487bf672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" license = {file = "LICENSE"} dependencies = [ # Dev dependencies needed for all of lfai - "openai", + "openai >= 1.21.1", "pip-tools == 7.3.0", "pytest", "httpx", diff --git a/src/leapfrogai_api/.env.example b/src/leapfrogai_api/.env.example new file mode 100644 index 000000000..58dc54c81 --- /dev/null +++ b/src/leapfrogai_api/.env.example @@ -0,0 +1,2 @@ +SUPABASE_KEY="" +SUPABASE_URL="http://localhost:54321" diff --git a/src/leapfrogai_api/backends/openai/__init__.py b/src/leapfrogai_api/backends/openai/__init__.py index 26c9082eb..e69de29bb 100644 --- a/src/leapfrogai_api/backends/openai/__init__.py +++ b/src/leapfrogai_api/backends/openai/__init__.py @@ -1,3 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter(prefix="/openai/v1", tags=["openai"]) diff --git a/src/leapfrogai_api/backends/openai/routes.py b/src/leapfrogai_api/backends/openai/routes.py index da4687474..bb879c26a 100644 --- a/src/leapfrogai_api/backends/openai/routes.py +++ b/src/leapfrogai_api/backends/openai/routes.py @@ -3,7 +3,6 @@ from fastapi import Depends, HTTPException import leapfrogai_sdk as lfai -from leapfrogai_api.backends.openai import router from leapfrogai_api.backends.openai.grpc_client import ( chat_completion, completion, @@ -25,6 +24,9 @@ ) from leapfrogai_api.utils import get_model_config from leapfrogai_api.utils.config import Config +from fastapi import APIRouter + +router = APIRouter(prefix="/openai/v1", tags=["openai"]) @router.post("/completions") diff --git a/src/leapfrogai_api/data/__init__.py b/src/leapfrogai_api/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py new file mode 100644 index 000000000..85fbd4878 --- /dev/null +++ b/src/leapfrogai_api/data/supabase_client.py @@ -0,0 +1,138 @@ +""" Wrapper class for interacting with the Supabase database. """ +import logging +import os +from dotenv import load_dotenv +from openai.types.beta import Assistant +from openai.types.beta.assistant import ToolResources +from supabase.client import Client, create_client +from leapfrogai_api.utils.openai_util import strings_to_tools, tools_to_strings + + +def get_connection() -> Client: + """Get the connection to the Supabase database.""" + try: + load_dotenv() + supabase_url = os.getenv("SUPABASE_URL") + supabase_key = os.getenv("SUPABASE_KEY") + supabase: Client = create_client( + supabase_url=supabase_url, supabase_key=supabase_key + ) + return supabase + except Exception as exc: + logging.error("Unable to connect to the Supabase database") + raise ConnectionError("Unable to connect to the Supabase database") from exc + + +class SupabaseWrapper: + """Wrapper class for interacting with the Supabase database.""" + + def __init__(self): + pass + + ### Assistant Methods ### + + def upsert_assistant(self, assistant: Assistant, client: Client = get_connection()): + """Create an assistant in the database.""" + + try: + client.table("assistant_objects").upsert( + [ + { + "id": assistant.id, + "object": assistant.object, + "created_at": assistant.created_at, + "name": assistant.name, + "description": assistant.description, + "model": assistant.model, + "instructions": assistant.instructions, + "tools": tools_to_strings(assistant.tools), + "tool_resources": ToolResources.model_dump_json( + assistant.tool_resources + ), + "metadata": assistant.metadata, + "top_p": assistant.top_p, + "temperature": assistant.temperature, + "response_format": assistant.response_format, + } + ] + ).execute() + return + + except Exception as exc: + raise ValueError("Unable to create the assistant") from exc + + def list_assistants(self, client: Client = get_connection()): + """List all the assistants in the database.""" + + try: + response = client.table("assistant_objects").select("*").execute() + print(response.data[0]["tools"]) + assistants = [ + Assistant( + id=data["id"], + object=data["object"], + created_at=data["created_at"], + name=data["name"], + description=data["description"], + model=data["model"], + instructions=data["instructions"], + tools=strings_to_tools(data["tools"]), + tool_resources=ToolResources.model_validate_json( + data["tool_resources"] + ), + metadata=data["metadata"], + top_p=data["top_p"], + temperature=data["temperature"], + response_format=data["response_format"], + ) + for data in response.data + ] + return assistants + except Exception as exc: + raise FileNotFoundError( + "No assistant objects found in the database" + ) from exc + + def retrieve_assistant(self, assistant_id, client: Client = get_connection()): + """Retrieve the assistant from the database.""" + + try: + response = ( + client.table("assistant_objects") + .select("*") + .eq("id", assistant_id) + .execute() + ) + data = response.data[0] + assistant = Assistant( + id=data["id"], + object=data["object"], + created_at=data["created_at"], + name=data["name"], + description=data["description"], + model=data["model"], + instructions=data["instructions"], + tools=strings_to_tools(data["tools"]), + tool_resources=ToolResources.model_validate_json( + data["tool_resources"] + ), + metadata=data["metadata"], + top_p=data["top_p"], + temperature=data["temperature"], + response_format=data["response_format"], + ) + return assistant + except Exception as exc: + raise FileNotFoundError( + f"No assistant found with id: {assistant_id}" + ) from exc + + def delete_assistant(self, assistant_id, client: Client = get_connection()): + """Delete the assistant from the database.""" + + try: + client.table("assistant_objects").delete().eq("id", assistant_id).execute() + except Exception as exc: + raise FileNotFoundError( + f"No assistant found with id: {assistant_id}" + ) from exc diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index de49d3fd5..badfbee47 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -5,8 +5,8 @@ from fastapi import FastAPI # We need to import all the functions in these files so the router decorator gets processed -from leapfrogai_api.backends.openai import router as openai_router -from leapfrogai_api.backends.openai.routes import * # noqa: F403 - We need to import all the functions in these files so the router decorator gets processed +from leapfrogai_api.backends.openai.routes import router as openai_router +import leapfrogai_api.routers.assistants as assistants from leapfrogai_api.utils import get_model_config @@ -37,3 +37,4 @@ async def models(): app.include_router(openai_router) +app.include_router(assistants.router) diff --git a/src/leapfrogai_api/pyproject.toml b/src/leapfrogai_api/pyproject.toml index e991be00f..64895808f 100644 --- a/src/leapfrogai_api/pyproject.toml +++ b/src/leapfrogai_api/pyproject.toml @@ -5,11 +5,13 @@ description = "An API for LeapfrogAI that allows LeapfrogAI backends to connect dependencies = [ "fastapi >= 0.109.1", + "openai >= 1.21.1", "uvicorn >= 0.23.2", - "pydantic", + "pydantic >= 2.0.0", "python-multipart >= 0.0.7", #indirect dep of FastAPI to receive form data for file uploads "watchfiles >= 0.21.0", "leapfrogai_sdk", + "supabase", ] requires-python = "~=3.11" diff --git a/src/leapfrogai_api/routers/__init__.py b/src/leapfrogai_api/routers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py new file mode 100644 index 000000000..0d1a419cd --- /dev/null +++ b/src/leapfrogai_api/routers/assistants.py @@ -0,0 +1,124 @@ +""" OpenAI Compliant Assistants API Router.""" +import time +from typing import List +from uuid import uuid4 +from fastapi import HTTPException, APIRouter +from openai.types.beta import Assistant, AssistantDeleted +from openai.types.beta.assistant import ToolResources +from leapfrogai_api.routers.assistants_types import ( + CreateAssistantRequest, + ModifyAssistantRequest, +) +from leapfrogai_api.data.supabase_client import SupabaseWrapper +from leapfrogai_api.utils.openai_util import validate_tools_typed_dict + +router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) + + +@router.post("/") +async def create_assistant(request: CreateAssistantRequest) -> Assistant: + """Create an assistant.""" + + print(request) + print(validate_tools_typed_dict(request.tools)) + print(ToolResources.model_validate(request.tool_resources)) + + try: + created_at = int(time.time()) + assistant_id = str(uuid4()) + + assistant = Assistant( + id=assistant_id, + created_at=created_at, + name=request.name, + description=request.description, + instructions=request.instructions, + model=request.model, + object="assistant", + tools=validate_tools_typed_dict(request.tools), + tool_resources=ToolResources.model_validate(request.tool_resources), + temperature=request.temperature, + top_p=request.top_p, + metadata=request.metadata, + response_format=request.response_format, + ) + + supabase_wrapper = SupabaseWrapper() + supabase_wrapper.upsert_assistant(assistant) + return assistant + + except Exception as exc: + raise HTTPException( + status_code=405, detail="Unable to create assistant" + ) from exc + + +@router.get("/") +async def list_assistants() -> List[Assistant]: + """List all the assistants.""" + try: + supabase_wrapper = SupabaseWrapper() + assistants: List[Assistant] = supabase_wrapper.list_assistants() + return assistants + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="No assistants found") from exc + + +@router.get("/{assistant_id}") +async def retrieve_assistant(assistant_id: str) -> Assistant: + """Retrieve an assistant.""" + try: + supabase_wrapper = SupabaseWrapper() + assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) + return assistant + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc + + +@router.post("/{assistant_id}") +async def modify_assistant( + assistant_id: str, request: ModifyAssistantRequest +) -> Assistant: + """Modify an assistant.""" + + print(request) + print(validate_tools_typed_dict(request.tools)) + print(ToolResources.model_validate(request.tool_resources)) + + try: + supabase_wrapper = SupabaseWrapper() + assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) + + assistant.model = request.model or assistant.model + assistant.name = request.name or assistant.name + assistant.description = request.description or assistant.description + assistant.instructions = request.instructions or assistant.instructions + if request.tools: + assistant.tools = validate_tools_typed_dict(request.tools) + + if request.tool_resources: + assistant.tool_resources = ToolResources.model_validate_json( + request.tool_resources + ) + + assistant.metadata = request.metadata or assistant.metadata + assistant.temperature = request.temperature or assistant.temperature + assistant.top_p = request.top_p or assistant.top_p + assistant.response_format = request.response_format or assistant.response_format + supabase_wrapper.upsert_assistant(assistant) + return assistant + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc + + +@router.delete("/{assistant_id}") +async def delete_assistant(assistant_id: str) -> AssistantDeleted: + """Delete an assistant.""" + try: + supabase_wrapper = SupabaseWrapper() + supabase_wrapper.delete_assistant(assistant_id) + return AssistantDeleted( + id=assistant_id, deleted=True, object="assistant.deleted" + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc diff --git a/src/leapfrogai_api/routers/assistants_types.py b/src/leapfrogai_api/routers/assistants_types.py new file mode 100644 index 000000000..fffce4bcc --- /dev/null +++ b/src/leapfrogai_api/routers/assistants_types.py @@ -0,0 +1,28 @@ +""" Typing definitions for assistants API. """ +from __future__ import annotations + +from typing import Optional, Literal +from pydantic import BaseModel + + +class CreateAssistantRequest(BaseModel): + """Request object for creating an assistant.""" + + model: str = "mistral" + name: Optional[str] = "Froggy Assistant" + description: Optional[str] = "A helpful assistant." + instructions: Optional[str] = "You are a helpful assistant." + tools: Optional[list[dict[Literal["type"], Literal["file_search"]]]] | None = [ + {"type": "file_search"} + ] # This is all we support right now + tool_resources: Optional[object] | None = {} + metadata: Optional[object] | None = {} + temperature: Optional[float] = 1.0 + top_p: Optional[float] = 1.0 + response_format: Optional[ + Literal["auto"] + ] | None = "auto" # This is all we support right now + + +class ModifyAssistantRequest(CreateAssistantRequest): + """Request object for modifying an assistant.""" diff --git a/src/leapfrogai_api/utils/openai_util.py b/src/leapfrogai_api/utils/openai_util.py new file mode 100644 index 000000000..930645764 --- /dev/null +++ b/src/leapfrogai_api/utils/openai_util.py @@ -0,0 +1,66 @@ +""" This module contains utility functions for interacting with OpenAI API. """ +import logging +from typing import Dict, List, Union +from openai.types.beta import ( + CodeInterpreterTool, + FileSearchTool, + FunctionTool, + AssistantTool, +) + +tool_mapping = { + "code_interpreter": CodeInterpreterTool, + "file_search": FileSearchTool, + "function_tool": FunctionTool, +} + + +def validate_tools_typed_dict(data: List[Dict]) -> List[AssistantTool]: + """Validate a tool typed dict.""" + for tool_data in data: + if "type" not in tool_data: + raise ValueError("Tool type not specified.") + + tool_type = tool_data["type"] + if tool_type not in tool_mapping: + raise ValueError(f"Unknown tool type: {tool_type}") + + tool_class = tool_mapping[tool_type] + tool_instance = tool_class(**tool_data) + + if tool_instance is None: + raise ValueError("No tools specified.") + + if len(data) > 128: + raise ValueError("Too many tools specified.") + + if isinstance(tool_instance, list): + return tool_instance + + return [tool_instance] + + +def strings_to_tools(tool_names: Union[str, List[str]]) -> List[AssistantTool]: + """Convert a list of tool names to a list of tool instances.""" + tools = [] + included_types = set() # Set to track included tool types + + if isinstance(tool_names, str): + tool_names = [tool_names] + + for name in tool_names: + if name in tool_mapping and name not in included_types: + tool_class = tool_mapping[name] + tool_instance = tool_class(type=name) + tools.append(tool_instance) + included_types.add(name) # Mark this type as included + elif name not in tool_mapping: + logging.warning("Unknown tool type: %s", name) + raise ValueError(f"Unknown tool type: {name}") + + return tools + + +def tools_to_strings(tools: List[AssistantTool]) -> List[str]: + """Convert a list of tool instances to a list of tool names.""" + return [tool.type for tool in tools] diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 000000000..a3ad88055 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,4 @@ +# Supabase +.branches +.temp +.env diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 000000000..55f3f8d43 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,161 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "leapfrogai" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. public and storage are always included. +schemas = ["public", "storage", "graphql_public"] +# Extra schemas to add to the search_path of every request. public is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv6) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = true +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }} ." + +# Use pre-defined map of phone number to OTP for testing. +[auth.sms.test_otp] +# 4152127777 = "123456" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +[auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20240419164109_init-assistant.sql b/supabase/migrations/20240419164109_init-assistant.sql new file mode 100644 index 000000000..8742e0349 --- /dev/null +++ b/supabase/migrations/20240419164109_init-assistant.sql @@ -0,0 +1,17 @@ +-- Create a table to store OpenAI Assistant Objects +create table + assistant_objects ( + id uuid primary key, + object text, + created_at bigint, + name text, + description text, + model text, + instructions text, + tools text[], + tool_resources jsonb, + metadata jsonb, + top_p float, + temperature float, + response_format text + ); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pytest/leapfrogai_api/test_assistants.py b/tests/pytest/leapfrogai_api/test_assistants.py new file mode 100644 index 000000000..1b4b22c31 --- /dev/null +++ b/tests/pytest/leapfrogai_api/test_assistants.py @@ -0,0 +1,80 @@ +""" Test the API endpoints for assistants. """ +from fastapi.testclient import TestClient +from openai.types.beta import Assistant + +from leapfrogai_api.main import app +from leapfrogai_api.routers.assistants_types import ( + CreateAssistantRequest, + ModifyAssistantRequest, +) + +client = TestClient(app) + + +def test_create_assistant(): + """Test creating an assistant.""" + request = CreateAssistantRequest( + model="test", + name="test", + description="test", + instructions="test", + tools=[{"type": "file_search"}], + tool_resources={}, + metadata={}, + temperature=1.0, + top_p=1.0, + response_format="auto", + ) + + create_response = client.post("/openai/v1/assistants", json=request.model_dump()) + assert create_response.status_code == 200 + assert Assistant.model_validate(create_response.json()) + + list_response = client.get("/openai/v1/assistants") + assert list_response.status_code == 200 + assert Assistant.model_validate(list_response.json()[0]) + + get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") + assert get_response.status_code == 200 + + request = ModifyAssistantRequest( + model="test1", + name="test1", + description="test1", + instructions="test1", + tools=[{"type": "file_search"}], + tool_resources={}, + metadata={}, + temperature=1.0, + top_p=1.0, + response_format="auto", + ) + + modify_response = client.post( + f"/openai/v1/assistants/{create_response.json()['id']}", + json=request.model_dump(), + ) + assert modify_response.status_code == 200 + assert Assistant.model_validate(modify_response.json()) + + get_modified_response = client.get( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert get_modified_response.status_code == 200 + + delete_response = client.delete( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert delete_response.status_code == 200 + + # Make sure the assistant is not still present + retrieve_assistant_response = client.get( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert retrieve_assistant_response.status_code == 404 + + +def test_assistants_not_exist(): + """Test responses for assistants that do not exist.""" + assert client.get("/openai/v1/assistants/123").status_code == 404 + assert client.delete("/openai/v1/assistants/123").status_code == 404 From ebea2d47f847df7f7e81dc023b27a5807336efc0 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Sun, 21 Apr 2024 23:07:29 -0400 Subject: [PATCH 02/73] adding in some files endpoints stuff --- .gitignore | 1 + src/leapfrogai_api/data/supabase_client.py | 165 +++++++++++++++++- src/leapfrogai_api/main.py | 4 +- src/leapfrogai_api/routers/assistants.py | 6 +- src/leapfrogai_api/routers/files.py | 84 +++++++++ .../routers/{assistants_types.py => types.py} | 21 +++ .../migrations/20240422015807_init-files.sql | 18 ++ .../pytest/leapfrogai_api/test_assistants.py | 2 +- 8 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 src/leapfrogai_api/routers/files.py rename src/leapfrogai_api/routers/{assistants_types.py => types.py} (64%) create mode 100644 supabase/migrations/20240422015807_init-files.sql diff --git a/.gitignore b/.gitignore index b201b237d..cad86e5ed 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ build/ .model/ *.gguf .env +.ruff_cache/ # local model and tokenizer files *.bin diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index 85fbd4878..249b923db 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -1,7 +1,10 @@ """ Wrapper class for interacting with the Supabase database. """ import logging import os +from typing import List from dotenv import load_dotenv +from fastapi import UploadFile +from openai.types import FileObject, FileDeleted from openai.types.beta import Assistant from openai.types.beta.assistant import ToolResources from supabase.client import Client, create_client @@ -10,6 +13,7 @@ def get_connection() -> Client: """Get the connection to the Supabase database.""" + try: load_dotenv() supabase_url = os.getenv("SUPABASE_URL") @@ -19,7 +23,7 @@ def get_connection() -> Client: ) return supabase except Exception as exc: - logging.error("Unable to connect to the Supabase database") + logging.error("Unable to connect to the Supabase database.") raise ConnectionError("Unable to connect to the Supabase database") from exc @@ -29,9 +33,157 @@ class SupabaseWrapper: def __init__(self): pass + ### File Methods ### + + def upsert_file( + self, + file: UploadFile, + file_object: FileObject, + client: Client = get_connection(), + ) -> FileObject: + """Upsert the documents and their embeddings in the database.""" + + try: + client.table("file_objects").upsert( + [ + { + "id": file_object.id, + "bytes": file_object.bytes, + "created_at": file_object.created_at, + "filename": file_object.filename, + "object": file_object.object, + "purpose": file_object.purpose, + "status": file_object.status, + "status_details": file_object.status_details, + } + ] + ).execute() + + client.storage.from_("file_bucket").upload( + file=file.file.read(), path=f"{file_object.id}" + ) + return file_object + except Exception as exc: + raise FileNotFoundError("Unable to store file") from exc + + def list_files( + self, purpose: str = "assistants", client: Client = get_connection() + ) -> list[FileObject]: + """List all the files in the database.""" + + try: + response = ( + client.table("file_objects") + .select("*") + .eq("purpose", purpose) + .execute() + ) + file_objects = [ + FileObject( + id=data["id"], + bytes=data["bytes"], + created_at=data["created_at"], + filename=data["filename"], + object=data["object"], + purpose=data["purpose"], + status=data["status"], + status_details=data["status_details"], + ) + for data in response.data + ] + return file_objects + except Exception as exc: + raise FileNotFoundError("No file objects found in the database") from exc + + def get_file_object(self, file_id, client: Client = get_connection()) -> FileObject: + """Get the file object from the database.""" + + try: + response = ( + client.table("file_objects").select("*").eq("id", file_id).execute() + ) + except Exception as exc: + raise FileNotFoundError( + f"No file found with the given id: {file_id}" + ) from exc + + if len(response.data) == 0: + raise FileNotFoundError(f"No file found with the given id: {file_id}") + + if len(response.data) > 1: + raise ValueError("Multiple files found with the same id") + + data = response.data[0] + file_object = FileObject( + id=data["id"], + bytes=data["bytes"], + created_at=data["created_at"], + filename=data["filename"], + object=data["object"], + purpose=data["purpose"], + status=data["status"], + status_details=data["status_details"], + ) + return file_object + + def delete_file(self, file_id, client: Client = get_connection()) -> FileDeleted: + """Delete the file and its vectors from the database.""" + + try: + # Delete the file object + file_path = ( + client.table("file_objects") + .select("filename") + .eq("id", file_id) + .execute() + .data + ) + + if len(file_path) == 0: + raise FileNotFoundError( + f"Delete FileObject: No file found with id: {file_id}" + ) + + client.table("file_objects").delete().eq("id", file_id).execute() + except Exception as exc: + raise FileNotFoundError( + f"Delete FileObject: No file found with id: {file_id}" + ) from exc + + try: + # Delete the file from bucket + client.storage.from_("file_bucket").remove(f"{file_id}") + except Exception as exc: + raise FileNotFoundError( + f"Delete File: No file found with id: {file_id}" + ) from exc + + return FileDeleted(id=file_id, object="file", deleted=True) + + def get_file_content(self, file_id, client: Client = get_connection()): + """Get the file content from the bucket.""" + + try: + file_path = ( + client.table("file_objects") + .select("filename") + .eq("id", file_id) + .execute() + .data + ) + + file_path = file_path[0]["filename"] + return client.storage.from_("file_bucket").download(f"{file_id}") + except Exception as exc: + raise FileNotFoundError( + f"Get FileContent: No file found with id: {file_id}" + ) from exc + ### Assistant Methods ### - def upsert_assistant(self, assistant: Assistant, client: Client = get_connection()): + def upsert_assistant( + self, assistant: Assistant, client: Client = get_connection() + ) -> Assistant: """Create an assistant in the database.""" try: @@ -56,17 +208,16 @@ def upsert_assistant(self, assistant: Assistant, client: Client = get_connection } ] ).execute() - return + return assistant except Exception as exc: raise ValueError("Unable to create the assistant") from exc - def list_assistants(self, client: Client = get_connection()): + def list_assistants(self, client: Client = get_connection()) -> List[Assistant]: """List all the assistants in the database.""" try: response = client.table("assistant_objects").select("*").execute() - print(response.data[0]["tools"]) assistants = [ Assistant( id=data["id"], @@ -93,7 +244,9 @@ def list_assistants(self, client: Client = get_connection()): "No assistant objects found in the database" ) from exc - def retrieve_assistant(self, assistant_id, client: Client = get_connection()): + def retrieve_assistant( + self, assistant_id, client: Client = get_connection() + ) -> Assistant: """Retrieve the assistant from the database.""" try: diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index badfbee47..cec80d883 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -6,7 +6,8 @@ # We need to import all the functions in these files so the router decorator gets processed from leapfrogai_api.backends.openai.routes import router as openai_router -import leapfrogai_api.routers.assistants as assistants +from leapfrogai_api.routers import assistants +from leapfrogai_api.routers import files from leapfrogai_api.utils import get_model_config @@ -38,3 +39,4 @@ async def models(): app.include_router(openai_router) app.include_router(assistants.router) +app.include_router(files.router) diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py index 0d1a419cd..bfb0382a4 100644 --- a/src/leapfrogai_api/routers/assistants.py +++ b/src/leapfrogai_api/routers/assistants.py @@ -5,7 +5,7 @@ from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources -from leapfrogai_api.routers.assistants_types import ( +from leapfrogai_api.routers.types import ( CreateAssistantRequest, ModifyAssistantRequest, ) @@ -81,10 +81,6 @@ async def modify_assistant( ) -> Assistant: """Modify an assistant.""" - print(request) - print(validate_tools_typed_dict(request.tools)) - print(ToolResources.model_validate(request.tool_resources)) - try: supabase_wrapper = SupabaseWrapper() assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) diff --git a/src/leapfrogai_api/routers/files.py b/src/leapfrogai_api/routers/files.py new file mode 100644 index 000000000..6e55d020f --- /dev/null +++ b/src/leapfrogai_api/routers/files.py @@ -0,0 +1,84 @@ +""" OpenAI Compliant Files API Router. """ +import time +from uuid import uuid4 as uuid + +from fastapi import Depends, APIRouter, HTTPException +from openai.types import FileObject, FileDeleted + +from leapfrogai_api.routers.types import UploadFileRequest +from leapfrogai_api.data.supabase_client import SupabaseWrapper + +router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) + + +@router.post("/") +async def upload_file( + request: UploadFileRequest = Depends(UploadFileRequest.as_form), +) -> FileObject: + """Upload a file.""" + + try: + file_object = FileObject( + id=str(uuid()), + bytes=request.file.size, + created_at=int(time.time()), + filename=request.file.filename, + object="file", # Per OpenAI Spec this should always be file + purpose="assistants", # we only support assistants for now + status="uploaded", + status_details=None, + ) + print(file_object) + except Exception as exc: + raise HTTPException(status_code=500, detail="Failed to parse file") from exc + + try: + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.upsert_file(request.file, file_object) + except Exception as exc: + raise HTTPException(status_code=500, detail="Failed to store file") from exc + + +@router.get("/") +async def list_files(): + """List all files.""" + try: + supabase_wrapper = SupabaseWrapper() + response = supabase_wrapper.list_files(purpose="assistants") + return {"data": response, "object": "list"} + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="No file objects found") from exc + + +@router.get("/{file_id}") +async def retrieve_file(file_id: str) -> FileObject: + """Retrieve a file.""" + supabase_wrapper = SupabaseWrapper() + try: + return supabase_wrapper.get_file_object(file_id=file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc + except ValueError as exc: + raise HTTPException( + status_code=500, detail="Multiple files found with same id" + ) from exc + + +@router.delete("/{file_id}") +async def delete_file(file_id: str) -> FileDeleted: + """Delete a file.""" + supabase_wrapper = SupabaseWrapper() + try: + return supabase_wrapper.delete_file(file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc + + +@router.get("/{file_id}/content") +async def retrieve_file_content(file_id: str): + """Retrieve the content of a file.""" + supabase_wrapper = SupabaseWrapper() + try: + return supabase_wrapper.get_file_content(file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc diff --git a/src/leapfrogai_api/routers/assistants_types.py b/src/leapfrogai_api/routers/types.py similarity index 64% rename from src/leapfrogai_api/routers/assistants_types.py rename to src/leapfrogai_api/routers/types.py index fffce4bcc..ab78cc7df 100644 --- a/src/leapfrogai_api/routers/assistants_types.py +++ b/src/leapfrogai_api/routers/types.py @@ -3,6 +3,27 @@ from typing import Optional, Literal from pydantic import BaseModel +from fastapi import UploadFile, Form, File + + +### Files Types ### +class UploadFileRequest(BaseModel): + """Request object for uploading a file.""" + + file: UploadFile + purpose: Literal["assistants"] | None = "assistants" + + @classmethod + def as_form( + cls, + file: UploadFile = File(...), + purpose: Optional[str] = Form("assistants"), + ) -> UploadFileRequest: + """Create an instance of the class from form data.""" + return cls(file=file, purpose=purpose) + + +### Assistants Types ### class CreateAssistantRequest(BaseModel): diff --git a/supabase/migrations/20240422015807_init-files.sql b/supabase/migrations/20240422015807_init-files.sql new file mode 100644 index 000000000..25d8f92bf --- /dev/null +++ b/supabase/migrations/20240422015807_init-files.sql @@ -0,0 +1,18 @@ +-- Create a table to store the OpenAI File Objects +create table + file_objects ( + id uuid primary key DEFAULT uuid_generate_v4(), + bytes int, + created_at bigint, + filename text, + object text, + purpose text, + status text, + status_details text + ); + +-- storage bucket for the files +insert into storage.buckets + (id, name, public) +values + ('file_bucket', 'files', true); diff --git a/tests/pytest/leapfrogai_api/test_assistants.py b/tests/pytest/leapfrogai_api/test_assistants.py index 1b4b22c31..c896fa7ba 100644 --- a/tests/pytest/leapfrogai_api/test_assistants.py +++ b/tests/pytest/leapfrogai_api/test_assistants.py @@ -3,7 +3,7 @@ from openai.types.beta import Assistant from leapfrogai_api.main import app -from leapfrogai_api.routers.assistants_types import ( +from leapfrogai_api.routers.types import ( CreateAssistantRequest, ModifyAssistantRequest, ) From d06166b2b7362ce200edfb81faf372fb2c89246f Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 10:44:38 -0400 Subject: [PATCH 03/73] Addressing formatting issues --- src/leapfrogai_api/data/supabase_client.py | 3 ++- src/leapfrogai_api/routers/assistants.py | 3 ++- src/leapfrogai_api/routers/files.py | 3 ++- src/leapfrogai_api/routers/types.py | 9 +++++---- src/leapfrogai_api/utils/openai_util.py | 3 ++- tests/pytest/leapfrogai_api/test_assistants.py | 3 ++- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index 249b923db..db046eb02 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -1,4 +1,5 @@ -""" Wrapper class for interacting with the Supabase database. """ +"""Wrapper class for interacting with the Supabase database.""" + import logging import os from typing import List diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py index bfb0382a4..de65b5b40 100644 --- a/src/leapfrogai_api/routers/assistants.py +++ b/src/leapfrogai_api/routers/assistants.py @@ -1,4 +1,5 @@ -""" OpenAI Compliant Assistants API Router.""" +"""OpenAI Compliant Assistants API Router.""" + import time from typing import List from uuid import uuid4 diff --git a/src/leapfrogai_api/routers/files.py b/src/leapfrogai_api/routers/files.py index 6e55d020f..3b9be6cc5 100644 --- a/src/leapfrogai_api/routers/files.py +++ b/src/leapfrogai_api/routers/files.py @@ -1,4 +1,5 @@ -""" OpenAI Compliant Files API Router. """ +"""OpenAI Compliant Files API Router.""" + import time from uuid import uuid4 as uuid diff --git a/src/leapfrogai_api/routers/types.py b/src/leapfrogai_api/routers/types.py index ab78cc7df..af0e8c991 100644 --- a/src/leapfrogai_api/routers/types.py +++ b/src/leapfrogai_api/routers/types.py @@ -1,4 +1,5 @@ -""" Typing definitions for assistants API. """ +"""Typing definitions for assistants API.""" + from __future__ import annotations from typing import Optional, Literal @@ -40,9 +41,9 @@ class CreateAssistantRequest(BaseModel): metadata: Optional[object] | None = {} temperature: Optional[float] = 1.0 top_p: Optional[float] = 1.0 - response_format: Optional[ - Literal["auto"] - ] | None = "auto" # This is all we support right now + response_format: Optional[Literal["auto"]] | None = ( + "auto" # This is all we support right now + ) class ModifyAssistantRequest(CreateAssistantRequest): diff --git a/src/leapfrogai_api/utils/openai_util.py b/src/leapfrogai_api/utils/openai_util.py index 930645764..79220eef9 100644 --- a/src/leapfrogai_api/utils/openai_util.py +++ b/src/leapfrogai_api/utils/openai_util.py @@ -1,4 +1,5 @@ -""" This module contains utility functions for interacting with OpenAI API. """ +"""This module contains utility functions for interacting with OpenAI API.""" + import logging from typing import Dict, List, Union from openai.types.beta import ( diff --git a/tests/pytest/leapfrogai_api/test_assistants.py b/tests/pytest/leapfrogai_api/test_assistants.py index c896fa7ba..2642e5fbd 100644 --- a/tests/pytest/leapfrogai_api/test_assistants.py +++ b/tests/pytest/leapfrogai_api/test_assistants.py @@ -1,4 +1,5 @@ -""" Test the API endpoints for assistants. """ +"""Test the API endpoints for assistants.""" + from fastapi.testclient import TestClient from openai.types.beta import Assistant From 8a0240dfad28750b8ad31f6e824192fbae565a82 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 11:11:58 -0400 Subject: [PATCH 04/73] add stubs for vector store --- src/leapfrogai_api/main.py | 8 +++- src/leapfrogai_api/routers/vector_store.py | 43 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/leapfrogai_api/routers/vector_store.py diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index cec80d883..6c256bb36 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -6,8 +6,11 @@ # We need to import all the functions in these files so the router decorator gets processed from leapfrogai_api.backends.openai.routes import router as openai_router -from leapfrogai_api.routers import assistants -from leapfrogai_api.routers import files +from leapfrogai_api.routers import ( + assistants, + files, + vector_store, +) from leapfrogai_api.utils import get_model_config @@ -40,3 +43,4 @@ async def models(): app.include_router(openai_router) app.include_router(assistants.router) app.include_router(files.router) +app.include_router(vector_store.router) diff --git a/src/leapfrogai_api/routers/vector_store.py b/src/leapfrogai_api/routers/vector_store.py new file mode 100644 index 000000000..5a6a98125 --- /dev/null +++ b/src/leapfrogai_api/routers/vector_store.py @@ -0,0 +1,43 @@ +"""OpenAI Compliant Vector Store API Router.""" + +from typing import List +from fastapi import HTTPException, APIRouter +from openai.types.beta import VectorStore + + +router = APIRouter(prefix="/openai/v1/vector_store", tags=["openai/vector_store"]) + + +@router.post("/") +def create_vector_store() -> VectorStore: + """Create a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/") +def list_vector_stores() -> List[VectorStore]: + """List all the vector stores.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{vector_store_id}") +def retrieve_vector_store(vector_store_id: str) -> VectorStore: + """Retrieve a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{vector_store_id}") +def modify_vector_store(vector_store_id: str) -> VectorStore: + """Modify a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.delete("/{vector_store_id}") +def delete_vector_store(vector_store_id: str) -> VectorStore: + """Delete a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") From ce14cdff833f971dbe8394f3f1145c8becf791ae Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 11:21:02 -0400 Subject: [PATCH 05/73] add threads stubs --- src/leapfrogai_api/main.py | 2 ++ src/leapfrogai_api/routers/threads.py | 35 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/leapfrogai_api/routers/threads.py diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index 6c256bb36..1ffb81b1e 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -9,6 +9,7 @@ from leapfrogai_api.routers import ( assistants, files, + threads, vector_store, ) from leapfrogai_api.utils import get_model_config @@ -43,4 +44,5 @@ async def models(): app.include_router(openai_router) app.include_router(assistants.router) app.include_router(files.router) +app.include_router(threads.router) app.include_router(vector_store.router) diff --git a/src/leapfrogai_api/routers/threads.py b/src/leapfrogai_api/routers/threads.py new file mode 100644 index 000000000..1e312cc7e --- /dev/null +++ b/src/leapfrogai_api/routers/threads.py @@ -0,0 +1,35 @@ +"""OpenAI Compliant Threads API Router.""" + +from fastapi import HTTPException, APIRouter +from openai.types.beta import Thread, ThreadDeleted + + +router = APIRouter(prefix="/openai/v1/threads", tags=["openai/threads"]) + + +@router.post("/") +def create_thread() -> Thread: + """Create a thread.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}") +def retrieve_thread(thread_id: str) -> Thread: + """Retrieve a thread.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}") +def modify_thread(thread_id: str) -> Thread: + """Modify a thread.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.delete("/{thread_id}") +def delete_thread(thread_id: str) -> ThreadDeleted: + """Delete a thread.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") From 2f5cc51b2aed556b71a486892826b9f71a609a0b Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 11:32:56 -0400 Subject: [PATCH 06/73] stub messages and vector store files --- src/leapfrogai_api/routers/threads.py | 31 +++++++++++++++++++++- src/leapfrogai_api/routers/vector_store.py | 24 +++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/leapfrogai_api/routers/threads.py b/src/leapfrogai_api/routers/threads.py index 1e312cc7e..be47372dd 100644 --- a/src/leapfrogai_api/routers/threads.py +++ b/src/leapfrogai_api/routers/threads.py @@ -1,7 +1,8 @@ """OpenAI Compliant Threads API Router.""" +from typing import List from fastapi import HTTPException, APIRouter -from openai.types.beta import Thread, ThreadDeleted +from openai.types.beta import Thread, ThreadDeleted, Message router = APIRouter(prefix="/openai/v1/threads", tags=["openai/threads"]) @@ -33,3 +34,31 @@ def delete_thread(thread_id: str) -> ThreadDeleted: """Delete a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}/messages") +def create_message(thread_id: str) -> Message: + """Create a message.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}/messages") +def list_messages(thread_id: str) -> List[Message]: + """List all the messages in a thread.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}/messages/{message_id}") +def retrieve_message(thread_id: str, message_id: str) -> Message: + """Retrieve a message.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}/messages/{message_id}") +def modify_message(thread_id: str, message_id: str) -> Message: + """Modify a message.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") diff --git a/src/leapfrogai_api/routers/vector_store.py b/src/leapfrogai_api/routers/vector_store.py index 5a6a98125..4d3f92e97 100644 --- a/src/leapfrogai_api/routers/vector_store.py +++ b/src/leapfrogai_api/routers/vector_store.py @@ -2,8 +2,7 @@ from typing import List from fastapi import HTTPException, APIRouter -from openai.types.beta import VectorStore - +from openai.types.beta import VectorStore, VectorStoreFile, VectorStoreDeleted router = APIRouter(prefix="/openai/v1/vector_store", tags=["openai/vector_store"]) @@ -41,3 +40,24 @@ def delete_vector_store(vector_store_id: str) -> VectorStore: """Delete a vector store.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{vector_store_id}/files") +def create_vector_store_file(vector_store_id: str) -> VectorStoreFile: + """Create a file in a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{vector_store_id}/files") +def list_vector_store_files(vector_store_id: str) -> List[VectorStoreFile]: + """List all the files in a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.delete("/{vector_store_id}/files/{file_id}") +def delete_vector_store_file(vector_store_id: str, file_id: str) -> VectorStoreDeleted: + """Delete a file in a vector store.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") From 1e816fd62f119363ca597c87ddb6108c44840116 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 11:39:10 -0400 Subject: [PATCH 07/73] fix openai typing refs --- src/leapfrogai_api/routers/threads.py | 3 ++- src/leapfrogai_api/routers/vector_store.py | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/leapfrogai_api/routers/threads.py b/src/leapfrogai_api/routers/threads.py index be47372dd..26c43146c 100644 --- a/src/leapfrogai_api/routers/threads.py +++ b/src/leapfrogai_api/routers/threads.py @@ -2,7 +2,8 @@ from typing import List from fastapi import HTTPException, APIRouter -from openai.types.beta import Thread, ThreadDeleted, Message +from openai.types.beta import Thread, ThreadDeleted +from openai.types.beta.threads import Message router = APIRouter(prefix="/openai/v1/threads", tags=["openai/threads"]) diff --git a/src/leapfrogai_api/routers/vector_store.py b/src/leapfrogai_api/routers/vector_store.py index 4d3f92e97..ebe0112b1 100644 --- a/src/leapfrogai_api/routers/vector_store.py +++ b/src/leapfrogai_api/routers/vector_store.py @@ -2,7 +2,8 @@ from typing import List from fastapi import HTTPException, APIRouter -from openai.types.beta import VectorStore, VectorStoreFile, VectorStoreDeleted +from openai.types.beta import VectorStore, VectorStoreDeleted +from openai.types.beta.vector_stores import VectorStoreFile, VectorStoreFileDeleted router = APIRouter(prefix="/openai/v1/vector_store", tags=["openai/vector_store"]) @@ -36,7 +37,7 @@ def modify_vector_store(vector_store_id: str) -> VectorStore: @router.delete("/{vector_store_id}") -def delete_vector_store(vector_store_id: str) -> VectorStore: +def delete_vector_store(vector_store_id: str) -> VectorStoreDeleted: """Delete a vector store.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @@ -57,7 +58,9 @@ def list_vector_store_files(vector_store_id: str) -> List[VectorStoreFile]: @router.delete("/{vector_store_id}/files/{file_id}") -def delete_vector_store_file(vector_store_id: str, file_id: str) -> VectorStoreDeleted: +def delete_vector_store_file( + vector_store_id: str, file_id: str +) -> VectorStoreFileDeleted: """Delete a file in a vector store.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") From ef39d18c4842408237dbe764a4834e4bfe3381e6 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 11:53:25 -0400 Subject: [PATCH 08/73] stubbing out runs and runs steps --- src/leapfrogai_api/routers/threads.py | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/leapfrogai_api/routers/threads.py b/src/leapfrogai_api/routers/threads.py index 26c43146c..67def7cf3 100644 --- a/src/leapfrogai_api/routers/threads.py +++ b/src/leapfrogai_api/routers/threads.py @@ -4,6 +4,8 @@ from fastapi import HTTPException, APIRouter from openai.types.beta import Thread, ThreadDeleted from openai.types.beta.threads import Message +from openai.types.beta.threads import Run +from openai.types.beta.threads.runs import RunStep router = APIRouter(prefix="/openai/v1/threads", tags=["openai/threads"]) @@ -63,3 +65,66 @@ def modify_message(thread_id: str, message_id: str) -> Message: """Modify a message.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}/runs") +def create_run(thread_id: str) -> Run: + """Create a run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/runs") +def create_thread_and_run(assistant_id: str) -> Run: + """Create a thread and run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}/runs") +def list_runs(thread_id: str) -> List[Run]: + """List all the runs in a thread.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}/runs/{run_id}") +def retrieve_run(thread_id: str, run_id: str) -> Run: + """Retrieve a run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}/runs/{run_id}") +def modify_run(thread_id: str, run_id: str) -> Run: + """Modify a run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}/runs/{run_id}/submit_tool_outputs") +def submit_tool_outputs(thread_id: str, run_id: str) -> Run: + """Submit tool outputs for a run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.post("/{thread_id}/runs/{run_id}/cancel") +def cancel_run(thread_id: str, run_id: str) -> Run: + """Cancel a run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}/runs/{run_id}/steps") +def list_run_steps(thread_id: str, run_id: str) -> List[RunStep]: + """List all the steps in a run.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{thread_id}/runs/{run_id}/steps/{step_id}") +def retrieve_run_step(thread_id: str, run_id: str, step_id: str) -> RunStep: + """Retrieve a step.""" + # TODO: Implement this function + raise HTTPException(status_code=501, detail="Not implemented") From bf2cb8c0b9b9acba69b36df313a54a7ec2fff9ab Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 14:27:30 -0400 Subject: [PATCH 09/73] typing issues, and removing tests --- src/leapfrogai_api/data/supabase_client.py | 16 +++- src/leapfrogai_api/routers/assistants.py | 5 +- .../pytest/leapfrogai_api/test_assistants.py | 81 ------------------- 3 files changed, 13 insertions(+), 89 deletions(-) delete mode 100644 tests/pytest/leapfrogai_api/test_assistants.py diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index db046eb02..2642bf7be 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -6,7 +6,7 @@ from dotenv import load_dotenv from fastapi import UploadFile from openai.types import FileObject, FileDeleted -from openai.types.beta import Assistant +from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources from supabase.client import Client, create_client from leapfrogai_api.utils.openai_util import strings_to_tools, tools_to_strings @@ -24,7 +24,10 @@ def get_connection() -> Client: ) return supabase except Exception as exc: - logging.error("Unable to connect to the Supabase database.") + logging.error( + "Unable to connect to the Supabase database at %s", + os.getenv("SUPABASE_URL"), + ) raise ConnectionError("Unable to connect to the Supabase database") from exc @@ -69,7 +72,7 @@ def upsert_file( def list_files( self, purpose: str = "assistants", client: Client = get_connection() - ) -> list[FileObject]: + ) -> List[FileObject]: """List all the files in the database.""" try: @@ -281,11 +284,16 @@ def retrieve_assistant( f"No assistant found with id: {assistant_id}" ) from exc - def delete_assistant(self, assistant_id, client: Client = get_connection()): + def delete_assistant( + self, assistant_id, client: Client = get_connection() + ) -> AssistantDeleted: """Delete the assistant from the database.""" try: client.table("assistant_objects").delete().eq("id", assistant_id).execute() + return AssistantDeleted( + id=assistant_id, deleted=True, object="assistant.deleted" + ) except Exception as exc: raise FileNotFoundError( f"No assistant found with id: {assistant_id}" diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py index de65b5b40..8ee99788e 100644 --- a/src/leapfrogai_api/routers/assistants.py +++ b/src/leapfrogai_api/routers/assistants.py @@ -113,9 +113,6 @@ async def delete_assistant(assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" try: supabase_wrapper = SupabaseWrapper() - supabase_wrapper.delete_assistant(assistant_id) - return AssistantDeleted( - id=assistant_id, deleted=True, object="assistant.deleted" - ) + return supabase_wrapper.delete_assistant(assistant_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Assistant not found") from exc diff --git a/tests/pytest/leapfrogai_api/test_assistants.py b/tests/pytest/leapfrogai_api/test_assistants.py deleted file mode 100644 index 2642e5fbd..000000000 --- a/tests/pytest/leapfrogai_api/test_assistants.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Test the API endpoints for assistants.""" - -from fastapi.testclient import TestClient -from openai.types.beta import Assistant - -from leapfrogai_api.main import app -from leapfrogai_api.routers.types import ( - CreateAssistantRequest, - ModifyAssistantRequest, -) - -client = TestClient(app) - - -def test_create_assistant(): - """Test creating an assistant.""" - request = CreateAssistantRequest( - model="test", - name="test", - description="test", - instructions="test", - tools=[{"type": "file_search"}], - tool_resources={}, - metadata={}, - temperature=1.0, - top_p=1.0, - response_format="auto", - ) - - create_response = client.post("/openai/v1/assistants", json=request.model_dump()) - assert create_response.status_code == 200 - assert Assistant.model_validate(create_response.json()) - - list_response = client.get("/openai/v1/assistants") - assert list_response.status_code == 200 - assert Assistant.model_validate(list_response.json()[0]) - - get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") - assert get_response.status_code == 200 - - request = ModifyAssistantRequest( - model="test1", - name="test1", - description="test1", - instructions="test1", - tools=[{"type": "file_search"}], - tool_resources={}, - metadata={}, - temperature=1.0, - top_p=1.0, - response_format="auto", - ) - - modify_response = client.post( - f"/openai/v1/assistants/{create_response.json()['id']}", - json=request.model_dump(), - ) - assert modify_response.status_code == 200 - assert Assistant.model_validate(modify_response.json()) - - get_modified_response = client.get( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) - assert get_modified_response.status_code == 200 - - delete_response = client.delete( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) - assert delete_response.status_code == 200 - - # Make sure the assistant is not still present - retrieve_assistant_response = client.get( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) - assert retrieve_assistant_response.status_code == 404 - - -def test_assistants_not_exist(): - """Test responses for assistants that do not exist.""" - assert client.get("/openai/v1/assistants/123").status_code == 404 - assert client.delete("/openai/v1/assistants/123").status_code == 404 From c6d49f7557670e29bc7d079ab5a2668da7593d66 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 15:16:00 -0400 Subject: [PATCH 10/73] only include endpoints if Supabase is available --- src/leapfrogai_api/data/supabase_client.py | 25 +++++++++++++++++----- src/leapfrogai_api/main.py | 25 +++++++++++++--------- tests/pytest/test_api.py | 3 ++- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index 2642bf7be..b3955cc90 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -3,7 +3,6 @@ import logging import os from typing import List -from dotenv import load_dotenv from fastapi import UploadFile from openai.types import FileObject, FileDeleted from openai.types.beta import Assistant, AssistantDeleted @@ -12,13 +11,12 @@ from leapfrogai_api.utils.openai_util import strings_to_tools, tools_to_strings -def get_connection() -> Client: +def get_connection( + supabase_url=os.getenv("SUPABASE_URL"), supabase_key=os.getenv("SUPABASE_KEY") +) -> Client: """Get the connection to the Supabase database.""" try: - load_dotenv() - supabase_url = os.getenv("SUPABASE_URL") - supabase_key = os.getenv("SUPABASE_KEY") supabase: Client = create_client( supabase_url=supabase_url, supabase_key=supabase_key ) @@ -31,6 +29,23 @@ def get_connection() -> Client: raise ConnectionError("Unable to connect to the Supabase database") from exc +def test_connection( + supabase_url=os.getenv("SUPABASE_URL"), supabase_key=os.getenv("SUPABASE_KEY") +) -> bool: + """Test the connection to the Supabase database.""" + + try: + supabase = get_connection(supabase_url=supabase_url, supabase_key=supabase_key) + supabase.storage.from_("file_bucket").list() + return True + except Exception: + logging.error( + "Unable to connect to the Supabase database at %s", + os.getenv("SUPABASE_URL"), + ) + return False + + class SupabaseWrapper: """Wrapper class for interacting with the Supabase database.""" diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index 1ffb81b1e..d6640a473 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -1,3 +1,5 @@ +""" Main FastAPI application for the LeapfrogAI API. """ + import asyncio import logging from contextlib import asynccontextmanager @@ -6,13 +8,9 @@ # We need to import all the functions in these files so the router decorator gets processed from leapfrogai_api.backends.openai.routes import router as openai_router -from leapfrogai_api.routers import ( - assistants, - files, - threads, - vector_store, -) +from leapfrogai_api.routers import assistants, files, threads, vector_store from leapfrogai_api.utils import get_model_config +from leapfrogai_api.data.supabase_client import test_connection # handle startup & shutdown tasks @@ -42,7 +40,14 @@ async def models(): app.include_router(openai_router) -app.include_router(assistants.router) -app.include_router(files.router) -app.include_router(threads.router) -app.include_router(vector_store.router) + +if test_connection(): + app.include_router(assistants.router) + app.include_router(files.router) + app.include_router(threads.router) + app.include_router(vector_store.router) +else: + logging.error("Failed to connect to Supabase, not including dependant routes.") + logging.error( + "To use the full LeapfrogAI API, please check your Supabase connection." + ) diff --git a/tests/pytest/test_api.py b/tests/pytest/test_api.py index e23d53769..5c46a5f18 100644 --- a/tests/pytest/test_api.py +++ b/tests/pytest/test_api.py @@ -3,9 +3,10 @@ import shutil import time -import leapfrogai_api.backends.openai.types as lfai_types import pytest from fastapi.testclient import TestClient + +import leapfrogai_api.backends.openai.types as lfai_types from leapfrogai_api.main import app # Set environment variables that the TestClient will use From b691ce2e10a65245a10a1db50312eb38c8868f2f Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 15:16:48 -0400 Subject: [PATCH 11/73] fix formatting issue --- src/leapfrogai_api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index d6640a473..8d43f1a97 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -1,4 +1,4 @@ -""" Main FastAPI application for the LeapfrogAI API. """ +"""Main FastAPI application for the LeapfrogAI API.""" import asyncio import logging From a94cdcb30ad00df148007b3f128abb0477e74efe Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 15:23:58 -0400 Subject: [PATCH 12/73] more robustly handle missing supabase --- src/leapfrogai_api/data/supabase_client.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index b3955cc90..6c6b05297 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -16,17 +16,19 @@ def get_connection( ) -> Client: """Get the connection to the Supabase database.""" + if not supabase_url or not supabase_key: + raise ConnectionError("Invalid Supabase URL or Key provided.") + try: supabase: Client = create_client( supabase_url=supabase_url, supabase_key=supabase_key ) return supabase except Exception as exc: - logging.error( - "Unable to connect to the Supabase database at %s", - os.getenv("SUPABASE_URL"), - ) - raise ConnectionError("Unable to connect to the Supabase database") from exc + logging.error("Unable to connect to Supabase database at %s", supabase_url) + raise ConnectionError( + f"Unable to connect to Supabase database at {supabase_url}" + ) from exc def test_connection( @@ -34,6 +36,10 @@ def test_connection( ) -> bool: """Test the connection to the Supabase database.""" + if not supabase_url or not supabase_key: + logging.error("Invalid Supabase URL or Key provided.") + return False + try: supabase = get_connection(supabase_url=supabase_url, supabase_key=supabase_key) supabase.storage.from_("file_bucket").list() From b914c74b51071a324baa4725907a9ee52cbd5fec Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 17:03:46 -0400 Subject: [PATCH 13/73] refactor supabase_wrapper --- .../api/chart/templates/api/deployment.yaml | 7 +++ packages/api/chart/values.yaml | 1 + packages/api/zarf.yaml | 7 +++ src/leapfrogai_api/data/supabase_client.py | 60 +++++++++---------- src/leapfrogai_api/routers/assistants.py | 4 -- 5 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/api/chart/templates/api/deployment.yaml b/packages/api/chart/templates/api/deployment.yaml index b8a4097bd..3a6787c51 100644 --- a/packages/api/chart/templates/api/deployment.yaml +++ b/packages/api/chart/templates/api/deployment.yaml @@ -45,6 +45,13 @@ spec: value: "*.toml" - name: PORT value: "{{ .Values.api.port }}" + - name: SUPABASE_URL + value: "{{ .Values.package.supabase_url }}" + - name: SUPABASE_KEY + valueFrom: + secretKeyRef: + name: supabase-jwt + key: anon-key ports: - containerPort: 8080 livenessProbe: diff --git a/packages/api/chart/values.yaml b/packages/api/chart/values.yaml index 529930905..bec1262e3 100644 --- a/packages/api/chart/values.yaml +++ b/packages/api/chart/values.yaml @@ -8,3 +8,4 @@ api: package: host: leapfrogai-api + supabase_url: '###ZARF_VAR_SUPABASE_URL###' diff --git a/packages/api/zarf.yaml b/packages/api/zarf.yaml index 79ae224c7..f4be40f50 100644 --- a/packages/api/zarf.yaml +++ b/packages/api/zarf.yaml @@ -14,6 +14,13 @@ constants: - name: KIWIGRID_VERSION value: "1.23.3" +variables: + - name: SUPABASE_URL + description: URL for supabase + prompt: true + default: supabase-kong.leapfrogai.svc.cluster.local:8080 + sensitive: false + components: - name: leapfrogai required: true diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index 6c6b05297..70ab690f2 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -55,8 +55,8 @@ def test_connection( class SupabaseWrapper: """Wrapper class for interacting with the Supabase database.""" - def __init__(self): - pass + def __init__(self, client: Client = None): + self.client = client if client else get_connection() ### File Methods ### @@ -64,12 +64,11 @@ def upsert_file( self, file: UploadFile, file_object: FileObject, - client: Client = get_connection(), ) -> FileObject: """Upsert the documents and their embeddings in the database.""" try: - client.table("file_objects").upsert( + self.client.table("file_objects").upsert( [ { "id": file_object.id, @@ -84,21 +83,19 @@ def upsert_file( ] ).execute() - client.storage.from_("file_bucket").upload( + self.client.storage.from_("file_bucket").upload( file=file.file.read(), path=f"{file_object.id}" ) return file_object except Exception as exc: raise FileNotFoundError("Unable to store file") from exc - def list_files( - self, purpose: str = "assistants", client: Client = get_connection() - ) -> List[FileObject]: + def list_files(self, purpose: str = "assistants") -> List[FileObject]: """List all the files in the database.""" try: response = ( - client.table("file_objects") + self.client.table("file_objects") .select("*") .eq("purpose", purpose) .execute() @@ -120,12 +117,15 @@ def list_files( except Exception as exc: raise FileNotFoundError("No file objects found in the database") from exc - def get_file_object(self, file_id, client: Client = get_connection()) -> FileObject: + def get_file_object(self, file_id) -> FileObject: """Get the file object from the database.""" try: response = ( - client.table("file_objects").select("*").eq("id", file_id).execute() + self.client.table("file_objects") + .select("*") + .eq("id", file_id) + .execute() ) except Exception as exc: raise FileNotFoundError( @@ -151,13 +151,13 @@ def get_file_object(self, file_id, client: Client = get_connection()) -> FileObj ) return file_object - def delete_file(self, file_id, client: Client = get_connection()) -> FileDeleted: + def delete_file(self, file_id) -> FileDeleted: """Delete the file and its vectors from the database.""" try: # Delete the file object file_path = ( - client.table("file_objects") + self.client.table("file_objects") .select("filename") .eq("id", file_id) .execute() @@ -169,7 +169,7 @@ def delete_file(self, file_id, client: Client = get_connection()) -> FileDeleted f"Delete FileObject: No file found with id: {file_id}" ) - client.table("file_objects").delete().eq("id", file_id).execute() + self.client.table("file_objects").delete().eq("id", file_id).execute() except Exception as exc: raise FileNotFoundError( f"Delete FileObject: No file found with id: {file_id}" @@ -177,7 +177,7 @@ def delete_file(self, file_id, client: Client = get_connection()) -> FileDeleted try: # Delete the file from bucket - client.storage.from_("file_bucket").remove(f"{file_id}") + self.client.storage.from_("file_bucket").remove(f"{file_id}") except Exception as exc: raise FileNotFoundError( f"Delete File: No file found with id: {file_id}" @@ -185,12 +185,12 @@ def delete_file(self, file_id, client: Client = get_connection()) -> FileDeleted return FileDeleted(id=file_id, object="file", deleted=True) - def get_file_content(self, file_id, client: Client = get_connection()): + def get_file_content(self, file_id): """Get the file content from the bucket.""" try: file_path = ( - client.table("file_objects") + self.client.table("file_objects") .select("filename") .eq("id", file_id) .execute() @@ -198,7 +198,7 @@ def get_file_content(self, file_id, client: Client = get_connection()): ) file_path = file_path[0]["filename"] - return client.storage.from_("file_bucket").download(f"{file_id}") + return self.client.storage.from_("file_bucket").download(f"{file_id}") except Exception as exc: raise FileNotFoundError( f"Get FileContent: No file found with id: {file_id}" @@ -206,13 +206,11 @@ def get_file_content(self, file_id, client: Client = get_connection()): ### Assistant Methods ### - def upsert_assistant( - self, assistant: Assistant, client: Client = get_connection() - ) -> Assistant: + def upsert_assistant(self, assistant: Assistant) -> Assistant: """Create an assistant in the database.""" try: - client.table("assistant_objects").upsert( + self.client.table("assistant_objects").upsert( [ { "id": assistant.id, @@ -238,11 +236,11 @@ def upsert_assistant( except Exception as exc: raise ValueError("Unable to create the assistant") from exc - def list_assistants(self, client: Client = get_connection()) -> List[Assistant]: + def list_assistants(self) -> List[Assistant]: """List all the assistants in the database.""" try: - response = client.table("assistant_objects").select("*").execute() + response = self.client.table("assistant_objects").select("*").execute() assistants = [ Assistant( id=data["id"], @@ -269,14 +267,12 @@ def list_assistants(self, client: Client = get_connection()) -> List[Assistant]: "No assistant objects found in the database" ) from exc - def retrieve_assistant( - self, assistant_id, client: Client = get_connection() - ) -> Assistant: + def retrieve_assistant(self, assistant_id) -> Assistant: """Retrieve the assistant from the database.""" try: response = ( - client.table("assistant_objects") + self.client.table("assistant_objects") .select("*") .eq("id", assistant_id) .execute() @@ -305,13 +301,13 @@ def retrieve_assistant( f"No assistant found with id: {assistant_id}" ) from exc - def delete_assistant( - self, assistant_id, client: Client = get_connection() - ) -> AssistantDeleted: + def delete_assistant(self, assistant_id) -> AssistantDeleted: """Delete the assistant from the database.""" try: - client.table("assistant_objects").delete().eq("id", assistant_id).execute() + self.client.table("assistant_objects").delete().eq( + "id", assistant_id + ).execute() return AssistantDeleted( id=assistant_id, deleted=True, object="assistant.deleted" ) diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py index 8ee99788e..b3a4d79ff 100644 --- a/src/leapfrogai_api/routers/assistants.py +++ b/src/leapfrogai_api/routers/assistants.py @@ -20,10 +20,6 @@ async def create_assistant(request: CreateAssistantRequest) -> Assistant: """Create an assistant.""" - print(request) - print(validate_tools_typed_dict(request.tools)) - print(ToolResources.model_validate(request.tool_resources)) - try: created_at = int(time.time()) assistant_id = str(uuid4()) From 51ba2fd8279c68d7531b605a6f7be1efb91ca2a8 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 22 Apr 2024 18:30:59 -0400 Subject: [PATCH 14/73] snazzy tests --- src/leapfrogai_api/routers/files.py | 10 +-- tests/pytest/leapfrogai_api/test_files.py | 76 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 tests/pytest/leapfrogai_api/test_files.py diff --git a/src/leapfrogai_api/routers/files.py b/src/leapfrogai_api/routers/files.py index 3b9be6cc5..4313773b8 100644 --- a/src/leapfrogai_api/routers/files.py +++ b/src/leapfrogai_api/routers/files.py @@ -54,8 +54,8 @@ async def list_files(): @router.get("/{file_id}") async def retrieve_file(file_id: str) -> FileObject: """Retrieve a file.""" - supabase_wrapper = SupabaseWrapper() try: + supabase_wrapper = SupabaseWrapper() return supabase_wrapper.get_file_object(file_id=file_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc @@ -68,9 +68,9 @@ async def retrieve_file(file_id: str) -> FileObject: @router.delete("/{file_id}") async def delete_file(file_id: str) -> FileDeleted: """Delete a file.""" - supabase_wrapper = SupabaseWrapper() try: - return supabase_wrapper.delete_file(file_id) + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.delete_file(file_id=file_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc @@ -78,8 +78,8 @@ async def delete_file(file_id: str) -> FileDeleted: @router.get("/{file_id}/content") async def retrieve_file_content(file_id: str): """Retrieve the content of a file.""" - supabase_wrapper = SupabaseWrapper() try: - return supabase_wrapper.get_file_content(file_id) + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.get_file_content(file_id=file_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc diff --git a/tests/pytest/leapfrogai_api/test_files.py b/tests/pytest/leapfrogai_api/test_files.py new file mode 100644 index 000000000..c5bdc5ec7 --- /dev/null +++ b/tests/pytest/leapfrogai_api/test_files.py @@ -0,0 +1,76 @@ +"""Test cases for files router""" + +from unittest.mock import patch +from fastapi.testclient import TestClient +from openai.types import FileObject, FileDeleted +from leapfrogai_api.routers.files import router + +client = TestClient(router) +test_file_object = FileObject( + id="1", + filename="test.jpg", + bytes=1000, + created_at=123456, + object="file", + purpose="assistants", + status="uploaded", + status_details=None, +) + +test_file_deleted = FileDeleted( + id="1", + object="file", + deleted=True, +) + +test_file_list = [ + test_file_object, + test_file_object.model_copy(update={"id": "2"}), +] + + +@patch("leapfrogai_api.routers.files.SupabaseWrapper.list_files") +def test_list_files(mock_list_files): + """Test list_files endpoint""" + mock_list_files.return_value = test_file_list + response = client.get("/openai/v1/files/") + assert response.status_code == 200 + assert response.json() == { + "data": [FileObject.model_dump(file) for file in test_file_list], + "object": "list", + } + + mock_list_files.assert_called_once() + + +@patch("leapfrogai_api.routers.files.SupabaseWrapper.get_file_object") +def test_retrieve_file(mock_get_file_object): + """Test retrieve_file endpoint""" + + mock_get_file_object.return_value = test_file_object + response = client.get("/openai/v1/files/1") + assert response.status_code == 200 + assert FileObject.model_validate(response.json()) + assert response.json() == FileObject.model_dump(test_file_object) + mock_get_file_object.assert_called_once_with(file_id="1") + + +@patch("leapfrogai_api.routers.files.SupabaseWrapper.delete_file") +def test_delete_file(mock_delete_file): + """Test delete_file endpoint""" + mock_delete_file.return_value = test_file_deleted + response = client.delete("/openai/v1/files/1") + assert response.status_code == 200 + assert FileDeleted.model_validate(response.json()) + assert response.json() == FileDeleted.model_dump(test_file_deleted) + mock_delete_file.assert_called_once_with(file_id="1") + + +@patch("leapfrogai_api.routers.files.SupabaseWrapper.get_file_content") +def test_get_file_content(mock_get_file_content): + """Test get_file_content endpoint""" + mock_get_file_content.return_value = b"test" + response = client.get("/openai/v1/files/1/content") + assert response.status_code == 200 + # assert response.content == b"test" + mock_get_file_content.assert_called_once_with(file_id="1") From 3ea2ef390d70705c247a120b75291a743ff3e1ef Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Tue, 23 Apr 2024 11:19:14 -0400 Subject: [PATCH 15/73] refactor Supabase Wrapper --- src/leapfrogai_api/data/supabase_client.py | 65 +++++++++++++--------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index 70ab690f2..c76c0de5b 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -56,7 +56,7 @@ class SupabaseWrapper: """Wrapper class for interacting with the Supabase database.""" def __init__(self, client: Client = None): - self.client = client if client else get_connection() + pass ### File Methods ### @@ -64,11 +64,13 @@ def upsert_file( self, file: UploadFile, file_object: FileObject, + client: Client = None, ) -> FileObject: """Upsert the documents and their embeddings in the database.""" try: - self.client.table("file_objects").upsert( + client = client if client else get_connection() + client.table("file_objects").upsert( [ { "id": file_object.id, @@ -83,19 +85,22 @@ def upsert_file( ] ).execute() - self.client.storage.from_("file_bucket").upload( + client.storage.from_("file_bucket").upload( file=file.file.read(), path=f"{file_object.id}" ) return file_object except Exception as exc: raise FileNotFoundError("Unable to store file") from exc - def list_files(self, purpose: str = "assistants") -> List[FileObject]: + def list_files( + self, purpose: str = "assistants", client: Client = None + ) -> List[FileObject]: """List all the files in the database.""" try: + client = client if client else get_connection() response = ( - self.client.table("file_objects") + client.table("file_objects") .select("*") .eq("purpose", purpose) .execute() @@ -117,15 +122,13 @@ def list_files(self, purpose: str = "assistants") -> List[FileObject]: except Exception as exc: raise FileNotFoundError("No file objects found in the database") from exc - def get_file_object(self, file_id) -> FileObject: + def get_file_object(self, file_id: str, client: Client = None) -> FileObject: """Get the file object from the database.""" try: + client = client if client else get_connection() response = ( - self.client.table("file_objects") - .select("*") - .eq("id", file_id) - .execute() + client.table("file_objects").select("*").eq("id", file_id).execute() ) except Exception as exc: raise FileNotFoundError( @@ -151,13 +154,14 @@ def get_file_object(self, file_id) -> FileObject: ) return file_object - def delete_file(self, file_id) -> FileDeleted: + def delete_file(self, file_id: str, client: Client = None) -> FileDeleted: """Delete the file and its vectors from the database.""" try: + client = client if client else get_connection() # Delete the file object file_path = ( - self.client.table("file_objects") + client.table("file_objects") .select("filename") .eq("id", file_id) .execute() @@ -169,7 +173,7 @@ def delete_file(self, file_id) -> FileDeleted: f"Delete FileObject: No file found with id: {file_id}" ) - self.client.table("file_objects").delete().eq("id", file_id).execute() + client.table("file_objects").delete().eq("id", file_id).execute() except Exception as exc: raise FileNotFoundError( f"Delete FileObject: No file found with id: {file_id}" @@ -177,7 +181,7 @@ def delete_file(self, file_id) -> FileDeleted: try: # Delete the file from bucket - self.client.storage.from_("file_bucket").remove(f"{file_id}") + client.storage.from_("file_bucket").remove(f"{file_id}") except Exception as exc: raise FileNotFoundError( f"Delete File: No file found with id: {file_id}" @@ -185,12 +189,13 @@ def delete_file(self, file_id) -> FileDeleted: return FileDeleted(id=file_id, object="file", deleted=True) - def get_file_content(self, file_id): + def get_file_content(self, file_id: str, client: Client = None): """Get the file content from the bucket.""" try: + client = client if client else get_connection() file_path = ( - self.client.table("file_objects") + client.table("file_objects") .select("filename") .eq("id", file_id) .execute() @@ -198,7 +203,7 @@ def get_file_content(self, file_id): ) file_path = file_path[0]["filename"] - return self.client.storage.from_("file_bucket").download(f"{file_id}") + return client.storage.from_("file_bucket").download(f"{file_id}") except Exception as exc: raise FileNotFoundError( f"Get FileContent: No file found with id: {file_id}" @@ -206,11 +211,14 @@ def get_file_content(self, file_id): ### Assistant Methods ### - def upsert_assistant(self, assistant: Assistant) -> Assistant: + def upsert_assistant( + self, assistant: Assistant, client: Client = None + ) -> Assistant: """Create an assistant in the database.""" try: - self.client.table("assistant_objects").upsert( + client = client if client else get_connection() + client.table("assistant_objects").upsert( [ { "id": assistant.id, @@ -236,11 +244,12 @@ def upsert_assistant(self, assistant: Assistant) -> Assistant: except Exception as exc: raise ValueError("Unable to create the assistant") from exc - def list_assistants(self) -> List[Assistant]: + def list_assistants(self, client: Client = None) -> List[Assistant]: """List all the assistants in the database.""" try: - response = self.client.table("assistant_objects").select("*").execute() + client = client if client else get_connection() + response = client.table("assistant_objects").select("*").execute() assistants = [ Assistant( id=data["id"], @@ -267,12 +276,13 @@ def list_assistants(self) -> List[Assistant]: "No assistant objects found in the database" ) from exc - def retrieve_assistant(self, assistant_id) -> Assistant: + def retrieve_assistant(self, assistant_id: str, client: Client = None) -> Assistant: """Retrieve the assistant from the database.""" try: + client = client if client else get_connection() response = ( - self.client.table("assistant_objects") + client.table("assistant_objects") .select("*") .eq("id", assistant_id) .execute() @@ -301,13 +311,14 @@ def retrieve_assistant(self, assistant_id) -> Assistant: f"No assistant found with id: {assistant_id}" ) from exc - def delete_assistant(self, assistant_id) -> AssistantDeleted: + def delete_assistant( + self, assistant_id: str, client: Client = None + ) -> AssistantDeleted: """Delete the assistant from the database.""" try: - self.client.table("assistant_objects").delete().eq( - "id", assistant_id - ).execute() + client = client if client else get_connection() + client.table("assistant_objects").delete().eq("id", assistant_id).execute() return AssistantDeleted( id=assistant_id, deleted=True, object="assistant.deleted" ) From 6011e591c283b75a9a2aaff71c45b0364dd53716 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Tue, 23 Apr 2024 19:25:36 -0400 Subject: [PATCH 16/73] remove supabase-jwt secret (for now) --- packages/api/chart/templates/api/deployment.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/chart/templates/api/deployment.yaml b/packages/api/chart/templates/api/deployment.yaml index 3a6787c51..2724023a3 100644 --- a/packages/api/chart/templates/api/deployment.yaml +++ b/packages/api/chart/templates/api/deployment.yaml @@ -47,11 +47,11 @@ spec: value: "{{ .Values.api.port }}" - name: SUPABASE_URL value: "{{ .Values.package.supabase_url }}" - - name: SUPABASE_KEY - valueFrom: - secretKeyRef: - name: supabase-jwt - key: anon-key + # - name: SUPABASE_KEY + # valueFrom: + # secretKeyRef: + # name: supabase-jwt + # key: anon-key ports: - containerPort: 8080 livenessProbe: From 80173b222a42173b65c6e8ee4ce951a2012c8135 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 24 Apr 2024 10:57:44 -0400 Subject: [PATCH 17/73] update stubs to async defs --- src/leapfrogai_api/routers/threads.py | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/leapfrogai_api/routers/threads.py b/src/leapfrogai_api/routers/threads.py index 67def7cf3..c730e1ea6 100644 --- a/src/leapfrogai_api/routers/threads.py +++ b/src/leapfrogai_api/routers/threads.py @@ -12,84 +12,84 @@ @router.post("/") -def create_thread() -> Thread: +async def create_thread() -> Thread: """Create a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}") -def retrieve_thread(thread_id: str) -> Thread: +async def retrieve_thread(thread_id: str) -> Thread: """Retrieve a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.post("/{thread_id}") -def modify_thread(thread_id: str) -> Thread: +async def modify_thread(thread_id: str) -> Thread: """Modify a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.delete("/{thread_id}") -def delete_thread(thread_id: str) -> ThreadDeleted: +async def delete_thread(thread_id: str) -> ThreadDeleted: """Delete a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.post("/{thread_id}/messages") -def create_message(thread_id: str) -> Message: +async def create_message(thread_id: str) -> Message: """Create a message.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}/messages") -def list_messages(thread_id: str) -> List[Message]: +async def list_messages(thread_id: str) -> List[Message]: """List all the messages in a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}/messages/{message_id}") -def retrieve_message(thread_id: str, message_id: str) -> Message: +async def retrieve_message(thread_id: str, message_id: str) -> Message: """Retrieve a message.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.post("/{thread_id}/messages/{message_id}") -def modify_message(thread_id: str, message_id: str) -> Message: +async def modify_message(thread_id: str, message_id: str) -> Message: """Modify a message.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.post("/{thread_id}/runs") -def create_run(thread_id: str) -> Run: +async def create_run(thread_id: str) -> Run: """Create a run.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.post("/runs") -def create_thread_and_run(assistant_id: str) -> Run: +async def create_thread_and_run(assistant_id: str) -> Run: """Create a thread and run.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}/runs") -def list_runs(thread_id: str) -> List[Run]: +async def list_runs(thread_id: str) -> List[Run]: """List all the runs in a thread.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}/runs/{run_id}") -def retrieve_run(thread_id: str, run_id: str) -> Run: +async def retrieve_run(thread_id: str, run_id: str) -> Run: """Retrieve a run.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @@ -103,28 +103,28 @@ def modify_run(thread_id: str, run_id: str) -> Run: @router.post("/{thread_id}/runs/{run_id}/submit_tool_outputs") -def submit_tool_outputs(thread_id: str, run_id: str) -> Run: +async def submit_tool_outputs(thread_id: str, run_id: str) -> Run: """Submit tool outputs for a run.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.post("/{thread_id}/runs/{run_id}/cancel") -def cancel_run(thread_id: str, run_id: str) -> Run: +async def cancel_run(thread_id: str, run_id: str) -> Run: """Cancel a run.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}/runs/{run_id}/steps") -def list_run_steps(thread_id: str, run_id: str) -> List[RunStep]: +async def list_run_steps(thread_id: str, run_id: str) -> List[RunStep]: """List all the steps in a run.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") @router.get("/{thread_id}/runs/{run_id}/steps/{step_id}") -def retrieve_run_step(thread_id: str, run_id: str, step_id: str) -> RunStep: +async def retrieve_run_step(thread_id: str, run_id: str, step_id: str) -> RunStep: """Retrieve a step.""" # TODO: Implement this function raise HTTPException(status_code=501, detail="Not implemented") From d3dda523e386403c6d60afb7adbe11b9d5220a86 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 29 Apr 2024 20:43:17 -0400 Subject: [PATCH 18/73] revert overarching pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e487bf672..c133c59f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" license = {file = "LICENSE"} dependencies = [ # Dev dependencies needed for all of lfai - "openai >= 1.21.1", + "openai", "pip-tools == 7.3.0", "pytest", "httpx", From 30ae75fa942a8a67e5649ed7a9503bb150a40dfa Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 29 Apr 2024 20:49:27 -0400 Subject: [PATCH 19/73] resolving more conflicts from main --- src/leapfrogai_api/main.py | 1 - src/leapfrogai_api/routers/assistants.py | 2 +- src/leapfrogai_api/routers/files.py | 2 +- src/leapfrogai_api/routers/threads.py | 130 --------------------- src/leapfrogai_api/routers/types.py | 50 -------- src/leapfrogai_api/routers/vector_store.py | 66 ----------- 6 files changed, 2 insertions(+), 249 deletions(-) delete mode 100644 src/leapfrogai_api/routers/threads.py delete mode 100644 src/leapfrogai_api/routers/types.py delete mode 100644 src/leapfrogai_api/routers/vector_store.py diff --git a/src/leapfrogai_api/main.py b/src/leapfrogai_api/main.py index cb74be6b1..3bf782959 100644 --- a/src/leapfrogai_api/main.py +++ b/src/leapfrogai_api/main.py @@ -19,7 +19,6 @@ vector_store, ) from leapfrogai_api.utils import get_model_config -from leapfrogai_api.data.supabase_client import test_connection # handle startup & shutdown tasks diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py index b3a4d79ff..efd7722c9 100644 --- a/src/leapfrogai_api/routers/assistants.py +++ b/src/leapfrogai_api/routers/assistants.py @@ -6,7 +6,7 @@ from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources -from leapfrogai_api.routers.types import ( +from leapfrogai_api.backend.types import ( CreateAssistantRequest, ModifyAssistantRequest, ) diff --git a/src/leapfrogai_api/routers/files.py b/src/leapfrogai_api/routers/files.py index 4313773b8..72aea7fa0 100644 --- a/src/leapfrogai_api/routers/files.py +++ b/src/leapfrogai_api/routers/files.py @@ -6,7 +6,7 @@ from fastapi import Depends, APIRouter, HTTPException from openai.types import FileObject, FileDeleted -from leapfrogai_api.routers.types import UploadFileRequest +from leapfrogai_api.backend.types import UploadFileRequest from leapfrogai_api.data.supabase_client import SupabaseWrapper router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) diff --git a/src/leapfrogai_api/routers/threads.py b/src/leapfrogai_api/routers/threads.py deleted file mode 100644 index c730e1ea6..000000000 --- a/src/leapfrogai_api/routers/threads.py +++ /dev/null @@ -1,130 +0,0 @@ -"""OpenAI Compliant Threads API Router.""" - -from typing import List -from fastapi import HTTPException, APIRouter -from openai.types.beta import Thread, ThreadDeleted -from openai.types.beta.threads import Message -from openai.types.beta.threads import Run -from openai.types.beta.threads.runs import RunStep - - -router = APIRouter(prefix="/openai/v1/threads", tags=["openai/threads"]) - - -@router.post("/") -async def create_thread() -> Thread: - """Create a thread.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}") -async def retrieve_thread(thread_id: str) -> Thread: - """Retrieve a thread.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}") -async def modify_thread(thread_id: str) -> Thread: - """Modify a thread.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.delete("/{thread_id}") -async def delete_thread(thread_id: str) -> ThreadDeleted: - """Delete a thread.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}/messages") -async def create_message(thread_id: str) -> Message: - """Create a message.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}/messages") -async def list_messages(thread_id: str) -> List[Message]: - """List all the messages in a thread.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}/messages/{message_id}") -async def retrieve_message(thread_id: str, message_id: str) -> Message: - """Retrieve a message.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}/messages/{message_id}") -async def modify_message(thread_id: str, message_id: str) -> Message: - """Modify a message.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}/runs") -async def create_run(thread_id: str) -> Run: - """Create a run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/runs") -async def create_thread_and_run(assistant_id: str) -> Run: - """Create a thread and run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}/runs") -async def list_runs(thread_id: str) -> List[Run]: - """List all the runs in a thread.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}/runs/{run_id}") -async def retrieve_run(thread_id: str, run_id: str) -> Run: - """Retrieve a run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}/runs/{run_id}") -def modify_run(thread_id: str, run_id: str) -> Run: - """Modify a run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}/runs/{run_id}/submit_tool_outputs") -async def submit_tool_outputs(thread_id: str, run_id: str) -> Run: - """Submit tool outputs for a run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{thread_id}/runs/{run_id}/cancel") -async def cancel_run(thread_id: str, run_id: str) -> Run: - """Cancel a run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}/runs/{run_id}/steps") -async def list_run_steps(thread_id: str, run_id: str) -> List[RunStep]: - """List all the steps in a run.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{thread_id}/runs/{run_id}/steps/{step_id}") -async def retrieve_run_step(thread_id: str, run_id: str, step_id: str) -> RunStep: - """Retrieve a step.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") diff --git a/src/leapfrogai_api/routers/types.py b/src/leapfrogai_api/routers/types.py deleted file mode 100644 index af0e8c991..000000000 --- a/src/leapfrogai_api/routers/types.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Typing definitions for assistants API.""" - -from __future__ import annotations - -from typing import Optional, Literal -from pydantic import BaseModel -from fastapi import UploadFile, Form, File - - -### Files Types ### -class UploadFileRequest(BaseModel): - """Request object for uploading a file.""" - - file: UploadFile - purpose: Literal["assistants"] | None = "assistants" - - @classmethod - def as_form( - cls, - file: UploadFile = File(...), - purpose: Optional[str] = Form("assistants"), - ) -> UploadFileRequest: - """Create an instance of the class from form data.""" - return cls(file=file, purpose=purpose) - - -### Assistants Types ### - - -class CreateAssistantRequest(BaseModel): - """Request object for creating an assistant.""" - - model: str = "mistral" - name: Optional[str] = "Froggy Assistant" - description: Optional[str] = "A helpful assistant." - instructions: Optional[str] = "You are a helpful assistant." - tools: Optional[list[dict[Literal["type"], Literal["file_search"]]]] | None = [ - {"type": "file_search"} - ] # This is all we support right now - tool_resources: Optional[object] | None = {} - metadata: Optional[object] | None = {} - temperature: Optional[float] = 1.0 - top_p: Optional[float] = 1.0 - response_format: Optional[Literal["auto"]] | None = ( - "auto" # This is all we support right now - ) - - -class ModifyAssistantRequest(CreateAssistantRequest): - """Request object for modifying an assistant.""" diff --git a/src/leapfrogai_api/routers/vector_store.py b/src/leapfrogai_api/routers/vector_store.py deleted file mode 100644 index ebe0112b1..000000000 --- a/src/leapfrogai_api/routers/vector_store.py +++ /dev/null @@ -1,66 +0,0 @@ -"""OpenAI Compliant Vector Store API Router.""" - -from typing import List -from fastapi import HTTPException, APIRouter -from openai.types.beta import VectorStore, VectorStoreDeleted -from openai.types.beta.vector_stores import VectorStoreFile, VectorStoreFileDeleted - -router = APIRouter(prefix="/openai/v1/vector_store", tags=["openai/vector_store"]) - - -@router.post("/") -def create_vector_store() -> VectorStore: - """Create a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/") -def list_vector_stores() -> List[VectorStore]: - """List all the vector stores.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{vector_store_id}") -def retrieve_vector_store(vector_store_id: str) -> VectorStore: - """Retrieve a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{vector_store_id}") -def modify_vector_store(vector_store_id: str) -> VectorStore: - """Modify a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.delete("/{vector_store_id}") -def delete_vector_store(vector_store_id: str) -> VectorStoreDeleted: - """Delete a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.post("/{vector_store_id}/files") -def create_vector_store_file(vector_store_id: str) -> VectorStoreFile: - """Create a file in a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.get("/{vector_store_id}/files") -def list_vector_store_files(vector_store_id: str) -> List[VectorStoreFile]: - """List all the files in a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") - - -@router.delete("/{vector_store_id}/files/{file_id}") -def delete_vector_store_file( - vector_store_id: str, file_id: str -) -> VectorStoreFileDeleted: - """Delete a file in a vector store.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") From 7fb9c23e7190f5402d8f8d023319b8eab1f35c98 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 29 Apr 2024 23:23:30 -0400 Subject: [PATCH 20/73] working on endpoints and migrations --- src/leapfrogai_api/data/supabase_client.py | 10 +- src/leapfrogai_api/routers/assistants.py | 114 ------------------ src/leapfrogai_api/routers/files.py | 85 ------------- .../routers/openai/assistants.py | 93 ++++++++++++-- src/leapfrogai_api/routers/openai/files.py | 67 ++++++++-- .../20240419164109_init-assistant.sql | 1 - .../migrations/20240422015807_init-files.sql | 1 - .../20240424005922_init-threads.sql | 21 ++++ 8 files changed, 159 insertions(+), 233 deletions(-) delete mode 100644 src/leapfrogai_api/routers/assistants.py delete mode 100644 src/leapfrogai_api/routers/files.py create mode 100644 supabase/migrations/20240424005922_init-threads.sql diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index c76c0de5b..6d47034e4 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -77,7 +77,6 @@ def upsert_file( "bytes": file_object.bytes, "created_at": file_object.created_at, "filename": file_object.filename, - "object": file_object.object, "purpose": file_object.purpose, "status": file_object.status, "status_details": file_object.status_details, @@ -111,7 +110,7 @@ def list_files( bytes=data["bytes"], created_at=data["created_at"], filename=data["filename"], - object=data["object"], + object="file", purpose=data["purpose"], status=data["status"], status_details=data["status_details"], @@ -147,7 +146,7 @@ def get_file_object(self, file_id: str, client: Client = None) -> FileObject: bytes=data["bytes"], created_at=data["created_at"], filename=data["filename"], - object=data["object"], + object="file", purpose=data["purpose"], status=data["status"], status_details=data["status_details"], @@ -222,7 +221,6 @@ def upsert_assistant( [ { "id": assistant.id, - "object": assistant.object, "created_at": assistant.created_at, "name": assistant.name, "description": assistant.description, @@ -253,7 +251,7 @@ def list_assistants(self, client: Client = None) -> List[Assistant]: assistants = [ Assistant( id=data["id"], - object=data["object"], + object="assistant", created_at=data["created_at"], name=data["name"], description=data["description"], @@ -290,7 +288,7 @@ def retrieve_assistant(self, assistant_id: str, client: Client = None) -> Assist data = response.data[0] assistant = Assistant( id=data["id"], - object=data["object"], + object="assistant", created_at=data["created_at"], name=data["name"], description=data["description"], diff --git a/src/leapfrogai_api/routers/assistants.py b/src/leapfrogai_api/routers/assistants.py deleted file mode 100644 index efd7722c9..000000000 --- a/src/leapfrogai_api/routers/assistants.py +++ /dev/null @@ -1,114 +0,0 @@ -"""OpenAI Compliant Assistants API Router.""" - -import time -from typing import List -from uuid import uuid4 -from fastapi import HTTPException, APIRouter -from openai.types.beta import Assistant, AssistantDeleted -from openai.types.beta.assistant import ToolResources -from leapfrogai_api.backend.types import ( - CreateAssistantRequest, - ModifyAssistantRequest, -) -from leapfrogai_api.data.supabase_client import SupabaseWrapper -from leapfrogai_api.utils.openai_util import validate_tools_typed_dict - -router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) - - -@router.post("/") -async def create_assistant(request: CreateAssistantRequest) -> Assistant: - """Create an assistant.""" - - try: - created_at = int(time.time()) - assistant_id = str(uuid4()) - - assistant = Assistant( - id=assistant_id, - created_at=created_at, - name=request.name, - description=request.description, - instructions=request.instructions, - model=request.model, - object="assistant", - tools=validate_tools_typed_dict(request.tools), - tool_resources=ToolResources.model_validate(request.tool_resources), - temperature=request.temperature, - top_p=request.top_p, - metadata=request.metadata, - response_format=request.response_format, - ) - - supabase_wrapper = SupabaseWrapper() - supabase_wrapper.upsert_assistant(assistant) - return assistant - - except Exception as exc: - raise HTTPException( - status_code=405, detail="Unable to create assistant" - ) from exc - - -@router.get("/") -async def list_assistants() -> List[Assistant]: - """List all the assistants.""" - try: - supabase_wrapper = SupabaseWrapper() - assistants: List[Assistant] = supabase_wrapper.list_assistants() - return assistants - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="No assistants found") from exc - - -@router.get("/{assistant_id}") -async def retrieve_assistant(assistant_id: str) -> Assistant: - """Retrieve an assistant.""" - try: - supabase_wrapper = SupabaseWrapper() - assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) - return assistant - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc - - -@router.post("/{assistant_id}") -async def modify_assistant( - assistant_id: str, request: ModifyAssistantRequest -) -> Assistant: - """Modify an assistant.""" - - try: - supabase_wrapper = SupabaseWrapper() - assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) - - assistant.model = request.model or assistant.model - assistant.name = request.name or assistant.name - assistant.description = request.description or assistant.description - assistant.instructions = request.instructions or assistant.instructions - if request.tools: - assistant.tools = validate_tools_typed_dict(request.tools) - - if request.tool_resources: - assistant.tool_resources = ToolResources.model_validate_json( - request.tool_resources - ) - - assistant.metadata = request.metadata or assistant.metadata - assistant.temperature = request.temperature or assistant.temperature - assistant.top_p = request.top_p or assistant.top_p - assistant.response_format = request.response_format or assistant.response_format - supabase_wrapper.upsert_assistant(assistant) - return assistant - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc - - -@router.delete("/{assistant_id}") -async def delete_assistant(assistant_id: str) -> AssistantDeleted: - """Delete an assistant.""" - try: - supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.delete_assistant(assistant_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc diff --git a/src/leapfrogai_api/routers/files.py b/src/leapfrogai_api/routers/files.py deleted file mode 100644 index 72aea7fa0..000000000 --- a/src/leapfrogai_api/routers/files.py +++ /dev/null @@ -1,85 +0,0 @@ -"""OpenAI Compliant Files API Router.""" - -import time -from uuid import uuid4 as uuid - -from fastapi import Depends, APIRouter, HTTPException -from openai.types import FileObject, FileDeleted - -from leapfrogai_api.backend.types import UploadFileRequest -from leapfrogai_api.data.supabase_client import SupabaseWrapper - -router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) - - -@router.post("/") -async def upload_file( - request: UploadFileRequest = Depends(UploadFileRequest.as_form), -) -> FileObject: - """Upload a file.""" - - try: - file_object = FileObject( - id=str(uuid()), - bytes=request.file.size, - created_at=int(time.time()), - filename=request.file.filename, - object="file", # Per OpenAI Spec this should always be file - purpose="assistants", # we only support assistants for now - status="uploaded", - status_details=None, - ) - print(file_object) - except Exception as exc: - raise HTTPException(status_code=500, detail="Failed to parse file") from exc - - try: - supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.upsert_file(request.file, file_object) - except Exception as exc: - raise HTTPException(status_code=500, detail="Failed to store file") from exc - - -@router.get("/") -async def list_files(): - """List all files.""" - try: - supabase_wrapper = SupabaseWrapper() - response = supabase_wrapper.list_files(purpose="assistants") - return {"data": response, "object": "list"} - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="No file objects found") from exc - - -@router.get("/{file_id}") -async def retrieve_file(file_id: str) -> FileObject: - """Retrieve a file.""" - try: - supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.get_file_object(file_id=file_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc - except ValueError as exc: - raise HTTPException( - status_code=500, detail="Multiple files found with same id" - ) from exc - - -@router.delete("/{file_id}") -async def delete_file(file_id: str) -> FileDeleted: - """Delete a file.""" - try: - supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.delete_file(file_id=file_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc - - -@router.get("/{file_id}/content") -async def retrieve_file_content(file_id: str): - """Retrieve the content of a file.""" - try: - supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.get_file_content(file_id=file_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index e425c6c7c..efd7722c9 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -1,34 +1,75 @@ """OpenAI Compliant Assistants API Router.""" +import time +from typing import List +from uuid import uuid4 from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted +from openai.types.beta.assistant import ToolResources from leapfrogai_api.backend.types import ( CreateAssistantRequest, ModifyAssistantRequest, ) +from leapfrogai_api.data.supabase_client import SupabaseWrapper +from leapfrogai_api.utils.openai_util import validate_tools_typed_dict router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) -@router.post("") +@router.post("/") async def create_assistant(request: CreateAssistantRequest) -> Assistant: """Create an assistant.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + created_at = int(time.time()) + assistant_id = str(uuid4()) -@router.get("") -async def list_assistants() -> list[Assistant]: + assistant = Assistant( + id=assistant_id, + created_at=created_at, + name=request.name, + description=request.description, + instructions=request.instructions, + model=request.model, + object="assistant", + tools=validate_tools_typed_dict(request.tools), + tool_resources=ToolResources.model_validate(request.tool_resources), + temperature=request.temperature, + top_p=request.top_p, + metadata=request.metadata, + response_format=request.response_format, + ) + + supabase_wrapper = SupabaseWrapper() + supabase_wrapper.upsert_assistant(assistant) + return assistant + + except Exception as exc: + raise HTTPException( + status_code=405, detail="Unable to create assistant" + ) from exc + + +@router.get("/") +async def list_assistants() -> List[Assistant]: """List all the assistants.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + assistants: List[Assistant] = supabase_wrapper.list_assistants() + return assistants + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="No assistants found") from exc @router.get("/{assistant_id}") async def retrieve_assistant(assistant_id: str) -> Assistant: """Retrieve an assistant.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) + return assistant + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc @router.post("/{assistant_id}") @@ -36,12 +77,38 @@ async def modify_assistant( assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + + try: + supabase_wrapper = SupabaseWrapper() + assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) + + assistant.model = request.model or assistant.model + assistant.name = request.name or assistant.name + assistant.description = request.description or assistant.description + assistant.instructions = request.instructions or assistant.instructions + if request.tools: + assistant.tools = validate_tools_typed_dict(request.tools) + + if request.tool_resources: + assistant.tool_resources = ToolResources.model_validate_json( + request.tool_resources + ) + + assistant.metadata = request.metadata or assistant.metadata + assistant.temperature = request.temperature or assistant.temperature + assistant.top_p = request.top_p or assistant.top_p + assistant.response_format = request.response_format or assistant.response_format + supabase_wrapper.upsert_assistant(assistant) + return assistant + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc @router.delete("/{assistant_id}") async def delete_assistant(assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.delete_assistant(assistant_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 63b5422cb..72aea7fa0 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -1,44 +1,85 @@ -"""OpenAI compliant Files API Router.""" +"""OpenAI Compliant Files API Router.""" + +import time +from uuid import uuid4 as uuid from fastapi import Depends, APIRouter, HTTPException from openai.types import FileObject, FileDeleted + from leapfrogai_api.backend.types import UploadFileRequest +from leapfrogai_api.data.supabase_client import SupabaseWrapper router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) -@router.post("") +@router.post("/") async def upload_file( request: UploadFileRequest = Depends(UploadFileRequest.as_form), ) -> FileObject: """Upload a file.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + + try: + file_object = FileObject( + id=str(uuid()), + bytes=request.file.size, + created_at=int(time.time()), + filename=request.file.filename, + object="file", # Per OpenAI Spec this should always be file + purpose="assistants", # we only support assistants for now + status="uploaded", + status_details=None, + ) + print(file_object) + except Exception as exc: + raise HTTPException(status_code=500, detail="Failed to parse file") from exc + + try: + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.upsert_file(request.file, file_object) + except Exception as exc: + raise HTTPException(status_code=500, detail="Failed to store file") from exc -@router.get("") +@router.get("/") async def list_files(): """List all files.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + response = supabase_wrapper.list_files(purpose="assistants") + return {"data": response, "object": "list"} + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="No file objects found") from exc @router.get("/{file_id}") async def retrieve_file(file_id: str) -> FileObject: """Retrieve a file.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.get_file_object(file_id=file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc + except ValueError as exc: + raise HTTPException( + status_code=500, detail="Multiple files found with same id" + ) from exc @router.delete("/{file_id}") async def delete_file(file_id: str) -> FileDeleted: """Delete a file.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.delete_file(file_id=file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc @router.get("/{file_id}/content") async def retrieve_file_content(file_id: str): """Retrieve the content of a file.""" - # TODO: Implement this function - raise HTTPException(status_code=501, detail="Not implemented") + try: + supabase_wrapper = SupabaseWrapper() + return supabase_wrapper.get_file_content(file_id=file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc diff --git a/supabase/migrations/20240419164109_init-assistant.sql b/supabase/migrations/20240419164109_init-assistant.sql index 8742e0349..6a62acae0 100644 --- a/supabase/migrations/20240419164109_init-assistant.sql +++ b/supabase/migrations/20240419164109_init-assistant.sql @@ -2,7 +2,6 @@ create table assistant_objects ( id uuid primary key, - object text, created_at bigint, name text, description text, diff --git a/supabase/migrations/20240422015807_init-files.sql b/supabase/migrations/20240422015807_init-files.sql index 25d8f92bf..865415e4e 100644 --- a/supabase/migrations/20240422015807_init-files.sql +++ b/supabase/migrations/20240422015807_init-files.sql @@ -5,7 +5,6 @@ create table bytes int, created_at bigint, filename text, - object text, purpose text, status text, status_details text diff --git a/supabase/migrations/20240424005922_init-threads.sql b/supabase/migrations/20240424005922_init-threads.sql new file mode 100644 index 000000000..0915414c8 --- /dev/null +++ b/supabase/migrations/20240424005922_init-threads.sql @@ -0,0 +1,21 @@ +-- Create a table to store the OpenAI Thread Objects +create table + thread_objects ( + id uuid primary key DEFAULT uuid_generate_v4(), + created_at bigint, + metadata jsonb + ); + +-- Create a table to store the OpenAI Message Objects +create table + message_objects ( + id uuid primary key DEFAULT uuid_generate_v4(), + created_at bigint, + thread_id uuid references thread_objects(id) on delete cascade, + role text, + content jsonb, + file_ids uuid[], + assistant_id uuid references assistant_objects(id) on delete set null, + run_id uuid, + metadata jsonb + ); From d4933fa034c975ed549995255c56aacfb7055f7f Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Tue, 30 Apr 2024 00:02:40 -0400 Subject: [PATCH 21/73] move to async supabase client --- src/leapfrogai_api/data/supabase_client.py | 189 +++++++++--------- src/leapfrogai_api/pyproject.toml | 1 + .../routers/openai/assistants.py | 12 +- src/leapfrogai_api/routers/openai/files.py | 10 +- tests/pytest/leapfrogai_api/test_files.py | 11 +- 5 files changed, 115 insertions(+), 108 deletions(-) diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py index 6d47034e4..dcf01d426 100644 --- a/src/leapfrogai_api/data/supabase_client.py +++ b/src/leapfrogai_api/data/supabase_client.py @@ -7,21 +7,28 @@ from openai.types import FileObject, FileDeleted from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources -from supabase.client import Client, create_client +from supabase_py_async import create_client, AsyncClient +from supabase_py_async.lib.client_options import ClientOptions + +# from supabase.client import Client, create_client from leapfrogai_api.utils.openai_util import strings_to_tools, tools_to_strings -def get_connection( +async def get_connection( supabase_url=os.getenv("SUPABASE_URL"), supabase_key=os.getenv("SUPABASE_KEY") -) -> Client: +) -> AsyncClient: """Get the connection to the Supabase database.""" if not supabase_url or not supabase_key: raise ConnectionError("Invalid Supabase URL or Key provided.") try: - supabase: Client = create_client( - supabase_url=supabase_url, supabase_key=supabase_key + supabase: AsyncClient = await create_client( + supabase_url=supabase_url, + supabase_key=supabase_key, + options=ClientOptions( + postgrest_client_timeout=10, storage_client_timeout=10 + ), ) return supabase except Exception as exc: @@ -31,58 +38,38 @@ def get_connection( ) from exc -def test_connection( - supabase_url=os.getenv("SUPABASE_URL"), supabase_key=os.getenv("SUPABASE_KEY") -) -> bool: - """Test the connection to the Supabase database.""" - - if not supabase_url or not supabase_key: - logging.error("Invalid Supabase URL or Key provided.") - return False - - try: - supabase = get_connection(supabase_url=supabase_url, supabase_key=supabase_key) - supabase.storage.from_("file_bucket").list() - return True - except Exception: - logging.error( - "Unable to connect to the Supabase database at %s", - os.getenv("SUPABASE_URL"), - ) - return False - - class SupabaseWrapper: """Wrapper class for interacting with the Supabase database.""" - def __init__(self, client: Client = None): - pass - ### File Methods ### - def upsert_file( + async def upsert_file( self, file: UploadFile, file_object: FileObject, - client: Client = None, + client: AsyncClient = None, ) -> FileObject: """Upsert the documents and their embeddings in the database.""" try: - client = client if client else get_connection() - client.table("file_objects").upsert( - [ - { - "id": file_object.id, - "bytes": file_object.bytes, - "created_at": file_object.created_at, - "filename": file_object.filename, - "purpose": file_object.purpose, - "status": file_object.status, - "status_details": file_object.status_details, - } - ] - ).execute() + client = client if client else await get_connection() + await ( + client.table("file_objects") + .upsert( + [ + { + "id": file_object.id, + "bytes": file_object.bytes, + "created_at": file_object.created_at, + "filename": file_object.filename, + "purpose": file_object.purpose, + "status": file_object.status, + "status_details": file_object.status_details, + } + ] + ) + .execute() + ) client.storage.from_("file_bucket").upload( file=file.file.read(), path=f"{file_object.id}" @@ -91,15 +78,15 @@ def upsert_file( except Exception as exc: raise FileNotFoundError("Unable to store file") from exc - def list_files( - self, purpose: str = "assistants", client: Client = None + async def list_files( + self, purpose: str = "assistants", client: AsyncClient = None ) -> List[FileObject]: """List all the files in the database.""" try: - client = client if client else get_connection() + client = client if client else await get_connection() response = ( - client.table("file_objects") + await client.table("file_objects") .select("*") .eq("purpose", purpose) .execute() @@ -121,13 +108,18 @@ def list_files( except Exception as exc: raise FileNotFoundError("No file objects found in the database") from exc - def get_file_object(self, file_id: str, client: Client = None) -> FileObject: + async def get_file_object( + self, file_id: str, client: AsyncClient = None + ) -> FileObject: """Get the file object from the database.""" try: - client = client if client else get_connection() + client = client if client else await get_connection() response = ( - client.table("file_objects").select("*").eq("id", file_id).execute() + await client.table("file_objects") + .select("*") + .eq("id", file_id) + .execute() ) except Exception as exc: raise FileNotFoundError( @@ -153,14 +145,16 @@ def get_file_object(self, file_id: str, client: Client = None) -> FileObject: ) return file_object - def delete_file(self, file_id: str, client: Client = None) -> FileDeleted: + async def delete_file( + self, file_id: str, client: AsyncClient = None + ) -> FileDeleted: """Delete the file and its vectors from the database.""" try: - client = client if client else get_connection() + client = client if client else await get_connection() # Delete the file object file_path = ( - client.table("file_objects") + await client.table("file_objects") .select("filename") .eq("id", file_id) .execute() @@ -172,7 +166,7 @@ def delete_file(self, file_id: str, client: Client = None) -> FileDeleted: f"Delete FileObject: No file found with id: {file_id}" ) - client.table("file_objects").delete().eq("id", file_id).execute() + await client.table("file_objects").delete().eq("id", file_id).execute() except Exception as exc: raise FileNotFoundError( f"Delete FileObject: No file found with id: {file_id}" @@ -188,13 +182,13 @@ def delete_file(self, file_id: str, client: Client = None) -> FileDeleted: return FileDeleted(id=file_id, object="file", deleted=True) - def get_file_content(self, file_id: str, client: Client = None): + async def get_file_content(self, file_id: str, client: AsyncClient = None): """Get the file content from the bucket.""" try: - client = client if client else get_connection() + client = client if client else await get_connection() file_path = ( - client.table("file_objects") + await client.table("file_objects") .select("filename") .eq("id", file_id) .execute() @@ -210,44 +204,48 @@ def get_file_content(self, file_id: str, client: Client = None): ### Assistant Methods ### - def upsert_assistant( - self, assistant: Assistant, client: Client = None + async def upsert_assistant( + self, assistant: Assistant, client: AsyncClient = None ) -> Assistant: """Create an assistant in the database.""" try: - client = client if client else get_connection() - client.table("assistant_objects").upsert( - [ - { - "id": assistant.id, - "created_at": assistant.created_at, - "name": assistant.name, - "description": assistant.description, - "model": assistant.model, - "instructions": assistant.instructions, - "tools": tools_to_strings(assistant.tools), - "tool_resources": ToolResources.model_dump_json( - assistant.tool_resources - ), - "metadata": assistant.metadata, - "top_p": assistant.top_p, - "temperature": assistant.temperature, - "response_format": assistant.response_format, - } - ] - ).execute() + client = client if client else await get_connection() + await ( + client.table("assistant_objects") + .upsert( + [ + { + "id": assistant.id, + "created_at": assistant.created_at, + "name": assistant.name, + "description": assistant.description, + "model": assistant.model, + "instructions": assistant.instructions, + "tools": tools_to_strings(assistant.tools), + "tool_resources": ToolResources.model_dump_json( + assistant.tool_resources + ), + "metadata": assistant.metadata, + "top_p": assistant.top_p, + "temperature": assistant.temperature, + "response_format": assistant.response_format, + } + ] + ) + .execute() + ) return assistant except Exception as exc: raise ValueError("Unable to create the assistant") from exc - def list_assistants(self, client: Client = None) -> List[Assistant]: + async def list_assistants(self, client: AsyncClient = None) -> List[Assistant]: """List all the assistants in the database.""" try: - client = client if client else get_connection() - response = client.table("assistant_objects").select("*").execute() + client = client if client else await get_connection() + response = await client.table("assistant_objects").select("*").execute() assistants = [ Assistant( id=data["id"], @@ -274,13 +272,15 @@ def list_assistants(self, client: Client = None) -> List[Assistant]: "No assistant objects found in the database" ) from exc - def retrieve_assistant(self, assistant_id: str, client: Client = None) -> Assistant: + async def retrieve_assistant( + self, assistant_id: str, client: AsyncClient = None + ) -> Assistant: """Retrieve the assistant from the database.""" try: - client = client if client else get_connection() + client = client if client else await get_connection() response = ( - client.table("assistant_objects") + await client.table("assistant_objects") .select("*") .eq("id", assistant_id) .execute() @@ -309,14 +309,19 @@ def retrieve_assistant(self, assistant_id: str, client: Client = None) -> Assist f"No assistant found with id: {assistant_id}" ) from exc - def delete_assistant( - self, assistant_id: str, client: Client = None + async def delete_assistant( + self, assistant_id: str, client: AsyncClient = None ) -> AssistantDeleted: """Delete the assistant from the database.""" try: - client = client if client else get_connection() - client.table("assistant_objects").delete().eq("id", assistant_id).execute() + client = client if client else await get_connection() + await ( + client.table("assistant_objects") + .delete() + .eq("id", assistant_id) + .execute() + ) return AssistantDeleted( id=assistant_id, deleted=True, object="assistant.deleted" ) diff --git a/src/leapfrogai_api/pyproject.toml b/src/leapfrogai_api/pyproject.toml index 64895808f..e257b790d 100644 --- a/src/leapfrogai_api/pyproject.toml +++ b/src/leapfrogai_api/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "watchfiles >= 0.21.0", "leapfrogai_sdk", "supabase", + "supabase-py-async", ] requires-python = "~=3.11" diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index efd7722c9..d3ca943d6 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -41,7 +41,7 @@ async def create_assistant(request: CreateAssistantRequest) -> Assistant: ) supabase_wrapper = SupabaseWrapper() - supabase_wrapper.upsert_assistant(assistant) + await supabase_wrapper.upsert_assistant(assistant) return assistant except Exception as exc: @@ -55,7 +55,7 @@ async def list_assistants() -> List[Assistant]: """List all the assistants.""" try: supabase_wrapper = SupabaseWrapper() - assistants: List[Assistant] = supabase_wrapper.list_assistants() + assistants: List[Assistant] = await supabase_wrapper.list_assistants() return assistants except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="No assistants found") from exc @@ -66,7 +66,7 @@ async def retrieve_assistant(assistant_id: str) -> Assistant: """Retrieve an assistant.""" try: supabase_wrapper = SupabaseWrapper() - assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) + assistant: Assistant = await supabase_wrapper.retrieve_assistant(assistant_id) return assistant except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Assistant not found") from exc @@ -80,7 +80,7 @@ async def modify_assistant( try: supabase_wrapper = SupabaseWrapper() - assistant: Assistant = supabase_wrapper.retrieve_assistant(assistant_id) + assistant: Assistant = await supabase_wrapper.retrieve_assistant(assistant_id) assistant.model = request.model or assistant.model assistant.name = request.name or assistant.name @@ -98,7 +98,7 @@ async def modify_assistant( assistant.temperature = request.temperature or assistant.temperature assistant.top_p = request.top_p or assistant.top_p assistant.response_format = request.response_format or assistant.response_format - supabase_wrapper.upsert_assistant(assistant) + await supabase_wrapper.upsert_assistant(assistant) return assistant except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Assistant not found") from exc @@ -109,6 +109,6 @@ async def delete_assistant(assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" try: supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.delete_assistant(assistant_id) + return await supabase_wrapper.delete_assistant(assistant_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Assistant not found") from exc diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 72aea7fa0..3e9bc1e88 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -35,7 +35,7 @@ async def upload_file( try: supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.upsert_file(request.file, file_object) + return await supabase_wrapper.upsert_file(request.file, file_object) except Exception as exc: raise HTTPException(status_code=500, detail="Failed to store file") from exc @@ -45,7 +45,7 @@ async def list_files(): """List all files.""" try: supabase_wrapper = SupabaseWrapper() - response = supabase_wrapper.list_files(purpose="assistants") + response = await supabase_wrapper.list_files(purpose="assistants") return {"data": response, "object": "list"} except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="No file objects found") from exc @@ -56,7 +56,7 @@ async def retrieve_file(file_id: str) -> FileObject: """Retrieve a file.""" try: supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.get_file_object(file_id=file_id) + return await supabase_wrapper.get_file_object(file_id=file_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc except ValueError as exc: @@ -70,7 +70,7 @@ async def delete_file(file_id: str) -> FileDeleted: """Delete a file.""" try: supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.delete_file(file_id=file_id) + return await supabase_wrapper.delete_file(file_id=file_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc @@ -80,6 +80,6 @@ async def retrieve_file_content(file_id: str): """Retrieve the content of a file.""" try: supabase_wrapper = SupabaseWrapper() - return supabase_wrapper.get_file_content(file_id=file_id) + return await supabase_wrapper.get_file_content(file_id=file_id) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc diff --git a/tests/pytest/leapfrogai_api/test_files.py b/tests/pytest/leapfrogai_api/test_files.py index c5bdc5ec7..0a95bb7e4 100644 --- a/tests/pytest/leapfrogai_api/test_files.py +++ b/tests/pytest/leapfrogai_api/test_files.py @@ -3,9 +3,10 @@ from unittest.mock import patch from fastapi.testclient import TestClient from openai.types import FileObject, FileDeleted -from leapfrogai_api.routers.files import router +from leapfrogai_api.routers.openai.files import router client = TestClient(router) + test_file_object = FileObject( id="1", filename="test.jpg", @@ -29,7 +30,7 @@ ] -@patch("leapfrogai_api.routers.files.SupabaseWrapper.list_files") +@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.list_files") def test_list_files(mock_list_files): """Test list_files endpoint""" mock_list_files.return_value = test_file_list @@ -43,7 +44,7 @@ def test_list_files(mock_list_files): mock_list_files.assert_called_once() -@patch("leapfrogai_api.routers.files.SupabaseWrapper.get_file_object") +@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.get_file_object") def test_retrieve_file(mock_get_file_object): """Test retrieve_file endpoint""" @@ -55,7 +56,7 @@ def test_retrieve_file(mock_get_file_object): mock_get_file_object.assert_called_once_with(file_id="1") -@patch("leapfrogai_api.routers.files.SupabaseWrapper.delete_file") +@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.delete_file") def test_delete_file(mock_delete_file): """Test delete_file endpoint""" mock_delete_file.return_value = test_file_deleted @@ -66,7 +67,7 @@ def test_delete_file(mock_delete_file): mock_delete_file.assert_called_once_with(file_id="1") -@patch("leapfrogai_api.routers.files.SupabaseWrapper.get_file_content") +@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.get_file_content") def test_get_file_content(mock_get_file_content): """Test get_file_content endpoint""" mock_get_file_content.return_value = b"test" From f14149e6bc2aa4f99457dd7f253ee05dd547b7c5 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Tue, 30 Apr 2024 19:18:23 -0400 Subject: [PATCH 22/73] [WIP] refactor assistants and files --- .../data/crud_assistant_object.py | 84 +++++ src/leapfrogai_api/data/crud_file_object.py | 76 ++++ src/leapfrogai_api/data/supabase_client.py | 331 ------------------ .../routers/openai/assistants.py | 61 +--- src/leapfrogai_api/routers/openai/files.py | 45 ++- .../routers/supabase_session.py | 15 + .../20240419164109_init-assistant.sql | 13 +- .../migrations/20240422015807_init-files.sql | 1 + tests/pytest/leapfrogai_api/test_files.py | 77 ---- 9 files changed, 224 insertions(+), 479 deletions(-) create mode 100644 src/leapfrogai_api/data/crud_assistant_object.py create mode 100644 src/leapfrogai_api/data/crud_file_object.py delete mode 100644 src/leapfrogai_api/data/supabase_client.py create mode 100644 src/leapfrogai_api/routers/supabase_session.py delete mode 100644 tests/pytest/leapfrogai_api/test_files.py diff --git a/src/leapfrogai_api/data/crud_assistant_object.py b/src/leapfrogai_api/data/crud_assistant_object.py new file mode 100644 index 000000000..94a5049b9 --- /dev/null +++ b/src/leapfrogai_api/data/crud_assistant_object.py @@ -0,0 +1,84 @@ +"""CRUD Operations for Assistant.""" + +from supabase_py_async import AsyncClient +from openai.types.beta import Assistant, AssistantDeleted + + +class CRUDAssistant: + """CRUD Operations for Assistant""" + + def __init__(self, model: type[Assistant]): + self.model = model + + async def create( + self, client: AsyncClient, assistant: Assistant + ) -> Assistant | None: + """Create a new assistant.""" + data, _count = ( + await client.table("assistant_objects") + .insert(assistant.model_dump()) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def get(self, client: AsyncClient, assistant_id: str) -> Assistant | None: + """Get an assistant by its ID.""" + data, _count = ( + await client.table("assistant_objects") + .select("*") + .eq("id", assistant_id) + .execute() + ) + + _, response = data + + if data: + return self.model(**response[0]) + return None + + async def list(self, client: AsyncClient) -> list[Assistant] | None: + """List all assistants.""" + data, _count = await client.table("assistant_objects").select("*").execute() + + _, response = data + + if response: + return [self.model(**item) for item in response] + return None + + async def update( + self, client: AsyncClient, assistant_id: str, assistant: Assistant + ) -> Assistant | None: + """Update an assistant by its ID.""" + data, _count = ( + await client.table("assistant_objects") + .update(assistant.model_dump()) + .eq("id", assistant_id) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def delete(self, client: AsyncClient, assistant_id: str) -> AssistantDeleted: + """Delete an assistant by its ID.""" + data, _count = ( + await client.table("assistant_objects") + .delete() + .eq("id", assistant_id) + .execute() + ) + + _, response = data + + return AssistantDeleted( + id=assistant_id, deleted=bool(response), object="assistant.deleted" + ) diff --git a/src/leapfrogai_api/data/crud_file_object.py b/src/leapfrogai_api/data/crud_file_object.py new file mode 100644 index 000000000..d434e4c81 --- /dev/null +++ b/src/leapfrogai_api/data/crud_file_object.py @@ -0,0 +1,76 @@ +"""CRUD Operations for FileObject""" + +from supabase_py_async import AsyncClient +from openai.types import FileObject, FileDeleted + + +class CRUDFileObject: + """CRUD Operations for FileObject""" + + def __init__(self, model: type[FileObject]): + self.model = model + + async def create( + self, client: AsyncClient, file_object: FileObject + ) -> FileObject | None: + """Create a new file object.""" + data, _count = ( + await client.table("file_objects") + .insert(file_object.model_dump()) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def get(self, client: AsyncClient, file_id: str) -> FileObject | None: + """Get a file object by its ID.""" + data, _count = ( + await client.table("file_objects").select("*").eq("id", file_id).execute() + ) + + _, response = data + + if data: + return self.model(**response[0]) + return None + + async def list(self, client: AsyncClient) -> list[FileObject] | None: + """List all file objects.""" + data, _count = await client.table("file_objects").select("*").execute() + + _, response = data + + if response: + return [self.model(**item) for item in response] + return None + + async def update( + self, client: AsyncClient, file_id: str, file_object: FileObject + ) -> FileObject | None: + """Update a file object by its ID.""" + data, _count = ( + await client.table("file_objects") + .update(file_object.model_dump()) + .eq("id", file_id) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def delete(self, client: AsyncClient, file_id: str) -> FileDeleted: + """Delete a file object by its ID.""" + data, _count = ( + await client.table("file_objects").delete().eq("id", file_id).execute() + ) + + _, response = data + + return FileDeleted(id=file_id, deleted=bool(response), object="file") diff --git a/src/leapfrogai_api/data/supabase_client.py b/src/leapfrogai_api/data/supabase_client.py deleted file mode 100644 index dcf01d426..000000000 --- a/src/leapfrogai_api/data/supabase_client.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Wrapper class for interacting with the Supabase database.""" - -import logging -import os -from typing import List -from fastapi import UploadFile -from openai.types import FileObject, FileDeleted -from openai.types.beta import Assistant, AssistantDeleted -from openai.types.beta.assistant import ToolResources -from supabase_py_async import create_client, AsyncClient -from supabase_py_async.lib.client_options import ClientOptions - -# from supabase.client import Client, create_client -from leapfrogai_api.utils.openai_util import strings_to_tools, tools_to_strings - - -async def get_connection( - supabase_url=os.getenv("SUPABASE_URL"), supabase_key=os.getenv("SUPABASE_KEY") -) -> AsyncClient: - """Get the connection to the Supabase database.""" - - if not supabase_url or not supabase_key: - raise ConnectionError("Invalid Supabase URL or Key provided.") - - try: - supabase: AsyncClient = await create_client( - supabase_url=supabase_url, - supabase_key=supabase_key, - options=ClientOptions( - postgrest_client_timeout=10, storage_client_timeout=10 - ), - ) - return supabase - except Exception as exc: - logging.error("Unable to connect to Supabase database at %s", supabase_url) - raise ConnectionError( - f"Unable to connect to Supabase database at {supabase_url}" - ) from exc - - -class SupabaseWrapper: - """Wrapper class for interacting with the Supabase database.""" - - ### File Methods ### - - async def upsert_file( - self, - file: UploadFile, - file_object: FileObject, - client: AsyncClient = None, - ) -> FileObject: - """Upsert the documents and their embeddings in the database.""" - - try: - client = client if client else await get_connection() - await ( - client.table("file_objects") - .upsert( - [ - { - "id": file_object.id, - "bytes": file_object.bytes, - "created_at": file_object.created_at, - "filename": file_object.filename, - "purpose": file_object.purpose, - "status": file_object.status, - "status_details": file_object.status_details, - } - ] - ) - .execute() - ) - - client.storage.from_("file_bucket").upload( - file=file.file.read(), path=f"{file_object.id}" - ) - return file_object - except Exception as exc: - raise FileNotFoundError("Unable to store file") from exc - - async def list_files( - self, purpose: str = "assistants", client: AsyncClient = None - ) -> List[FileObject]: - """List all the files in the database.""" - - try: - client = client if client else await get_connection() - response = ( - await client.table("file_objects") - .select("*") - .eq("purpose", purpose) - .execute() - ) - file_objects = [ - FileObject( - id=data["id"], - bytes=data["bytes"], - created_at=data["created_at"], - filename=data["filename"], - object="file", - purpose=data["purpose"], - status=data["status"], - status_details=data["status_details"], - ) - for data in response.data - ] - return file_objects - except Exception as exc: - raise FileNotFoundError("No file objects found in the database") from exc - - async def get_file_object( - self, file_id: str, client: AsyncClient = None - ) -> FileObject: - """Get the file object from the database.""" - - try: - client = client if client else await get_connection() - response = ( - await client.table("file_objects") - .select("*") - .eq("id", file_id) - .execute() - ) - except Exception as exc: - raise FileNotFoundError( - f"No file found with the given id: {file_id}" - ) from exc - - if len(response.data) == 0: - raise FileNotFoundError(f"No file found with the given id: {file_id}") - - if len(response.data) > 1: - raise ValueError("Multiple files found with the same id") - - data = response.data[0] - file_object = FileObject( - id=data["id"], - bytes=data["bytes"], - created_at=data["created_at"], - filename=data["filename"], - object="file", - purpose=data["purpose"], - status=data["status"], - status_details=data["status_details"], - ) - return file_object - - async def delete_file( - self, file_id: str, client: AsyncClient = None - ) -> FileDeleted: - """Delete the file and its vectors from the database.""" - - try: - client = client if client else await get_connection() - # Delete the file object - file_path = ( - await client.table("file_objects") - .select("filename") - .eq("id", file_id) - .execute() - .data - ) - - if len(file_path) == 0: - raise FileNotFoundError( - f"Delete FileObject: No file found with id: {file_id}" - ) - - await client.table("file_objects").delete().eq("id", file_id).execute() - except Exception as exc: - raise FileNotFoundError( - f"Delete FileObject: No file found with id: {file_id}" - ) from exc - - try: - # Delete the file from bucket - client.storage.from_("file_bucket").remove(f"{file_id}") - except Exception as exc: - raise FileNotFoundError( - f"Delete File: No file found with id: {file_id}" - ) from exc - - return FileDeleted(id=file_id, object="file", deleted=True) - - async def get_file_content(self, file_id: str, client: AsyncClient = None): - """Get the file content from the bucket.""" - - try: - client = client if client else await get_connection() - file_path = ( - await client.table("file_objects") - .select("filename") - .eq("id", file_id) - .execute() - .data - ) - - file_path = file_path[0]["filename"] - return client.storage.from_("file_bucket").download(f"{file_id}") - except Exception as exc: - raise FileNotFoundError( - f"Get FileContent: No file found with id: {file_id}" - ) from exc - - ### Assistant Methods ### - - async def upsert_assistant( - self, assistant: Assistant, client: AsyncClient = None - ) -> Assistant: - """Create an assistant in the database.""" - - try: - client = client if client else await get_connection() - await ( - client.table("assistant_objects") - .upsert( - [ - { - "id": assistant.id, - "created_at": assistant.created_at, - "name": assistant.name, - "description": assistant.description, - "model": assistant.model, - "instructions": assistant.instructions, - "tools": tools_to_strings(assistant.tools), - "tool_resources": ToolResources.model_dump_json( - assistant.tool_resources - ), - "metadata": assistant.metadata, - "top_p": assistant.top_p, - "temperature": assistant.temperature, - "response_format": assistant.response_format, - } - ] - ) - .execute() - ) - return assistant - - except Exception as exc: - raise ValueError("Unable to create the assistant") from exc - - async def list_assistants(self, client: AsyncClient = None) -> List[Assistant]: - """List all the assistants in the database.""" - - try: - client = client if client else await get_connection() - response = await client.table("assistant_objects").select("*").execute() - assistants = [ - Assistant( - id=data["id"], - object="assistant", - created_at=data["created_at"], - name=data["name"], - description=data["description"], - model=data["model"], - instructions=data["instructions"], - tools=strings_to_tools(data["tools"]), - tool_resources=ToolResources.model_validate_json( - data["tool_resources"] - ), - metadata=data["metadata"], - top_p=data["top_p"], - temperature=data["temperature"], - response_format=data["response_format"], - ) - for data in response.data - ] - return assistants - except Exception as exc: - raise FileNotFoundError( - "No assistant objects found in the database" - ) from exc - - async def retrieve_assistant( - self, assistant_id: str, client: AsyncClient = None - ) -> Assistant: - """Retrieve the assistant from the database.""" - - try: - client = client if client else await get_connection() - response = ( - await client.table("assistant_objects") - .select("*") - .eq("id", assistant_id) - .execute() - ) - data = response.data[0] - assistant = Assistant( - id=data["id"], - object="assistant", - created_at=data["created_at"], - name=data["name"], - description=data["description"], - model=data["model"], - instructions=data["instructions"], - tools=strings_to_tools(data["tools"]), - tool_resources=ToolResources.model_validate_json( - data["tool_resources"] - ), - metadata=data["metadata"], - top_p=data["top_p"], - temperature=data["temperature"], - response_format=data["response_format"], - ) - return assistant - except Exception as exc: - raise FileNotFoundError( - f"No assistant found with id: {assistant_id}" - ) from exc - - async def delete_assistant( - self, assistant_id: str, client: AsyncClient = None - ) -> AssistantDeleted: - """Delete the assistant from the database.""" - - try: - client = client if client else await get_connection() - await ( - client.table("assistant_objects") - .delete() - .eq("id", assistant_id) - .execute() - ) - return AssistantDeleted( - id=assistant_id, deleted=True, object="assistant.deleted" - ) - except Exception as exc: - raise FileNotFoundError( - f"No assistant found with id: {assistant_id}" - ) from exc diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index d3ca943d6..3f025e99a 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -10,14 +10,17 @@ CreateAssistantRequest, ModifyAssistantRequest, ) -from leapfrogai_api.data.supabase_client import SupabaseWrapper +from leapfrogai_api.routers.supabase_session import Session from leapfrogai_api.utils.openai_util import validate_tools_typed_dict +from leapfrogai_api.data.crud_assistant_object import CRUDAssistant router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) @router.post("/") -async def create_assistant(request: CreateAssistantRequest) -> Assistant: +async def create_assistant( + session: Session, request: CreateAssistantRequest +) -> Assistant: """Create an assistant.""" try: @@ -40,9 +43,8 @@ async def create_assistant(request: CreateAssistantRequest) -> Assistant: response_format=request.response_format, ) - supabase_wrapper = SupabaseWrapper() - await supabase_wrapper.upsert_assistant(assistant) - return assistant + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.create(assistant=assistant, client=session) except Exception as exc: raise HTTPException( @@ -51,64 +53,39 @@ async def create_assistant(request: CreateAssistantRequest) -> Assistant: @router.get("/") -async def list_assistants() -> List[Assistant]: +async def list_assistants(session: Session) -> List[Assistant] | None: """List all the assistants.""" try: - supabase_wrapper = SupabaseWrapper() - assistants: List[Assistant] = await supabase_wrapper.list_assistants() - return assistants + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.list(client=session) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="No assistants found") from exc @router.get("/{assistant_id}") -async def retrieve_assistant(assistant_id: str) -> Assistant: +async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant: """Retrieve an assistant.""" try: - supabase_wrapper = SupabaseWrapper() - assistant: Assistant = await supabase_wrapper.retrieve_assistant(assistant_id) - return assistant + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.get(assistant_id=assistant_id, client=session) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Assistant not found") from exc @router.post("/{assistant_id}") async def modify_assistant( - assistant_id: str, request: ModifyAssistantRequest + session: Session, assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" - - try: - supabase_wrapper = SupabaseWrapper() - assistant: Assistant = await supabase_wrapper.retrieve_assistant(assistant_id) - - assistant.model = request.model or assistant.model - assistant.name = request.name or assistant.name - assistant.description = request.description or assistant.description - assistant.instructions = request.instructions or assistant.instructions - if request.tools: - assistant.tools = validate_tools_typed_dict(request.tools) - - if request.tool_resources: - assistant.tool_resources = ToolResources.model_validate_json( - request.tool_resources - ) - - assistant.metadata = request.metadata or assistant.metadata - assistant.temperature = request.temperature or assistant.temperature - assistant.top_p = request.top_p or assistant.top_p - assistant.response_format = request.response_format or assistant.response_format - await supabase_wrapper.upsert_assistant(assistant) - return assistant - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc + # TODO: Implement this function + raise HTTPException(status_code=405, detail="Not Implemented") @router.delete("/{assistant_id}") -async def delete_assistant(assistant_id: str) -> AssistantDeleted: +async def delete_assistant(session: Session, assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" try: - supabase_wrapper = SupabaseWrapper() - return await supabase_wrapper.delete_assistant(assistant_id) + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.delete(assistant_id=assistant_id, client=session) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Assistant not found") from exc diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 3e9bc1e88..3863f124d 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -3,21 +3,23 @@ import time from uuid import uuid4 as uuid -from fastapi import Depends, APIRouter, HTTPException -from openai.types import FileObject, FileDeleted +from fastapi import APIRouter, Depends, HTTPException +from openai.types import FileDeleted, FileObject from leapfrogai_api.backend.types import UploadFileRequest -from leapfrogai_api.data.supabase_client import SupabaseWrapper +from leapfrogai_api.data.crud_file_object import CRUDFileObject +from leapfrogai_api.routers.supabase_session import Session router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) @router.post("/") async def upload_file( + client: Session, request: UploadFileRequest = Depends(UploadFileRequest.as_form), ) -> FileObject: """Upload a file.""" - + # TODO: Store file in Supabase Storage try: file_object = FileObject( id=str(uuid()), @@ -29,34 +31,34 @@ async def upload_file( status="uploaded", status_details=None, ) - print(file_object) except Exception as exc: raise HTTPException(status_code=500, detail="Failed to parse file") from exc try: - supabase_wrapper = SupabaseWrapper() - return await supabase_wrapper.upsert_file(request.file, file_object) + crud_file = CRUDFileObject(model=FileObject) + await crud_file.create(file_object=file_object, client=client) except Exception as exc: raise HTTPException(status_code=500, detail="Failed to store file") from exc + return file_object + @router.get("/") -async def list_files(): +async def list_files(session: Session): """List all files.""" try: - supabase_wrapper = SupabaseWrapper() - response = await supabase_wrapper.list_files(purpose="assistants") - return {"data": response, "object": "list"} + crud_file = CRUDFileObject(model=FileObject) + return await crud_file.list(client=session) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="No file objects found") from exc @router.get("/{file_id}") -async def retrieve_file(file_id: str) -> FileObject: +async def retrieve_file(client: Session, file_id: str) -> FileObject: """Retrieve a file.""" try: - supabase_wrapper = SupabaseWrapper() - return await supabase_wrapper.get_file_object(file_id=file_id) + crud_file = CRUDFileObject(model=FileObject) + return await crud_file.get(file_id=file_id, client=client) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc except ValueError as exc: @@ -66,20 +68,17 @@ async def retrieve_file(file_id: str) -> FileObject: @router.delete("/{file_id}") -async def delete_file(file_id: str) -> FileDeleted: +async def delete_file(session: Session, file_id: str) -> FileDeleted: """Delete a file.""" try: - supabase_wrapper = SupabaseWrapper() - return await supabase_wrapper.delete_file(file_id=file_id) + crud_file = CRUDFileObject(model=FileObject) + return await crud_file.delete(file_id=file_id, client=session) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc @router.get("/{file_id}/content") -async def retrieve_file_content(file_id: str): +async def retrieve_file_content(session: Session, file_id: str): """Retrieve the content of a file.""" - try: - supabase_wrapper = SupabaseWrapper() - return await supabase_wrapper.get_file_content(file_id=file_id) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc + # TODO: Retrieve file content from Supabase Storage + raise HTTPException(status_code=501, detail="Not implemented") diff --git a/src/leapfrogai_api/routers/supabase_session.py b/src/leapfrogai_api/routers/supabase_session.py new file mode 100644 index 000000000..765a71141 --- /dev/null +++ b/src/leapfrogai_api/routers/supabase_session.py @@ -0,0 +1,15 @@ +import os +from typing import Annotated +from fastapi import Depends +from supabase_py_async import AsyncClient, create_client + + +async def init_supabase_client() -> AsyncClient: + """Initialize a Supabase client.""" + return await create_client( + supabase_key=os.getenv("SUPABASE_KEY"), + supabase_url=os.getenv("SUPABASE_URL"), + ) + + +Session = Annotated[AsyncClient, Depends(init_supabase_client)] diff --git a/supabase/migrations/20240419164109_init-assistant.sql b/supabase/migrations/20240419164109_init-assistant.sql index 6a62acae0..594af36cb 100644 --- a/supabase/migrations/20240419164109_init-assistant.sql +++ b/supabase/migrations/20240419164109_init-assistant.sql @@ -3,14 +3,15 @@ create table assistant_objects ( id uuid primary key, created_at bigint, - name text, description text, - model text, instructions text, - tools text[], - tool_resources jsonb, metadata jsonb, - top_p float, + model text, + name text, + object text, + tools jsonb, + response_format text, temperature float, - response_format text + tool_resources jsonb, + top_p float ); diff --git a/supabase/migrations/20240422015807_init-files.sql b/supabase/migrations/20240422015807_init-files.sql index 865415e4e..25d8f92bf 100644 --- a/supabase/migrations/20240422015807_init-files.sql +++ b/supabase/migrations/20240422015807_init-files.sql @@ -5,6 +5,7 @@ create table bytes int, created_at bigint, filename text, + object text, purpose text, status text, status_details text diff --git a/tests/pytest/leapfrogai_api/test_files.py b/tests/pytest/leapfrogai_api/test_files.py deleted file mode 100644 index 0a95bb7e4..000000000 --- a/tests/pytest/leapfrogai_api/test_files.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Test cases for files router""" - -from unittest.mock import patch -from fastapi.testclient import TestClient -from openai.types import FileObject, FileDeleted -from leapfrogai_api.routers.openai.files import router - -client = TestClient(router) - -test_file_object = FileObject( - id="1", - filename="test.jpg", - bytes=1000, - created_at=123456, - object="file", - purpose="assistants", - status="uploaded", - status_details=None, -) - -test_file_deleted = FileDeleted( - id="1", - object="file", - deleted=True, -) - -test_file_list = [ - test_file_object, - test_file_object.model_copy(update={"id": "2"}), -] - - -@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.list_files") -def test_list_files(mock_list_files): - """Test list_files endpoint""" - mock_list_files.return_value = test_file_list - response = client.get("/openai/v1/files/") - assert response.status_code == 200 - assert response.json() == { - "data": [FileObject.model_dump(file) for file in test_file_list], - "object": "list", - } - - mock_list_files.assert_called_once() - - -@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.get_file_object") -def test_retrieve_file(mock_get_file_object): - """Test retrieve_file endpoint""" - - mock_get_file_object.return_value = test_file_object - response = client.get("/openai/v1/files/1") - assert response.status_code == 200 - assert FileObject.model_validate(response.json()) - assert response.json() == FileObject.model_dump(test_file_object) - mock_get_file_object.assert_called_once_with(file_id="1") - - -@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.delete_file") -def test_delete_file(mock_delete_file): - """Test delete_file endpoint""" - mock_delete_file.return_value = test_file_deleted - response = client.delete("/openai/v1/files/1") - assert response.status_code == 200 - assert FileDeleted.model_validate(response.json()) - assert response.json() == FileDeleted.model_dump(test_file_deleted) - mock_delete_file.assert_called_once_with(file_id="1") - - -@patch("leapfrogai_api.routers.openai.files.SupabaseWrapper.get_file_content") -def test_get_file_content(mock_get_file_content): - """Test get_file_content endpoint""" - mock_get_file_content.return_value = b"test" - response = client.get("/openai/v1/files/1/content") - assert response.status_code == 200 - # assert response.content == b"test" - mock_get_file_content.assert_called_once_with(file_id="1") From c3c098feb093d415b320b5b41e0fa98e5af0a01a Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Tue, 30 Apr 2024 19:20:28 -0400 Subject: [PATCH 23/73] remove redundant / --- src/leapfrogai_api/routers/openai/assistants.py | 4 ++-- src/leapfrogai_api/routers/openai/files.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 3f025e99a..c8fee0f04 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -17,7 +17,7 @@ router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) -@router.post("/") +@router.post("") async def create_assistant( session: Session, request: CreateAssistantRequest ) -> Assistant: @@ -52,7 +52,7 @@ async def create_assistant( ) from exc -@router.get("/") +@router.get("") async def list_assistants(session: Session) -> List[Assistant] | None: """List all the assistants.""" try: diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 3863f124d..e169535b9 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -13,7 +13,7 @@ router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) -@router.post("/") +@router.post("") async def upload_file( client: Session, request: UploadFileRequest = Depends(UploadFileRequest.as_form), @@ -43,7 +43,7 @@ async def upload_file( return file_object -@router.get("/") +@router.get("") async def list_files(session: Session): """List all files.""" try: From cae2e02dbf38ecd1668be38d769e8cdc51116ff6 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Tue, 30 Apr 2024 19:39:16 -0400 Subject: [PATCH 24/73] [WIP] CRUD for Threads/Messages --- .../data/crud_message_object.py | 84 +++++++++++++++++++ src/leapfrogai_api/data/crud_thread_object.py | 75 +++++++++++++++++ src/leapfrogai_api/pyproject.toml | 2 +- .../20240424005922_init-threads.sql | 19 +++-- 4 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/leapfrogai_api/data/crud_message_object.py create mode 100644 src/leapfrogai_api/data/crud_thread_object.py diff --git a/src/leapfrogai_api/data/crud_message_object.py b/src/leapfrogai_api/data/crud_message_object.py new file mode 100644 index 000000000..5a7478181 --- /dev/null +++ b/src/leapfrogai_api/data/crud_message_object.py @@ -0,0 +1,84 @@ +"""CRUD operations for the Message object.""" + +from supabase_py_async import AsyncClient +from openai.types.beta.threads import Message + + +class CRUDMessageObject: + """CRUD Operations for MessageObject""" + + def __init__(self, model: type[Message]): + self.model = model + + async def create( + self, client: AsyncClient, message_object: Message + ) -> Message | None: + """Create a new message object.""" + data, _count = ( + await client.table("message_objects") + .insert(message_object.model_dump()) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def get(self, client: AsyncClient, message_id: str) -> Message | None: + """Get a message object by its ID.""" + data, _count = ( + await client.table("message_objects") + .select("*") + .eq("id", message_id) + .execute() + ) + + _, response = data + + if data: + return self.model(**response[0]) + return None + + async def list(self, client: AsyncClient) -> list[Message] | None: + """List all message objects.""" + data, _count = await client.table("message_objects").select("*").execute() + + _, response = data + + if response: + return [self.model(**item) for item in response] + return None + + async def update( + self, client: AsyncClient, message_id: str, message_object: Message + ) -> Message | None: + """Update a message object by its ID.""" + data, _count = ( + await client.table("message_objects") + .update(message_object.model_dump()) + .eq("id", message_id) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def delete(self, client: AsyncClient, message_id: str) -> Message | None: + """Delete a message object by its ID.""" + data, _count = ( + await client.table("message_objects") + .delete() + .eq("id", message_id) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None diff --git a/src/leapfrogai_api/data/crud_thread_object.py b/src/leapfrogai_api/data/crud_thread_object.py new file mode 100644 index 000000000..88ef88050 --- /dev/null +++ b/src/leapfrogai_api/data/crud_thread_object.py @@ -0,0 +1,75 @@ +"""CRUD Operations for ThreadObject.""" + +from supabase_py_async import AsyncClient +from openai.types.beta import Thread, ThreadDeleted + + +class CRUDThreadObject: + """CRUD Operations for ThreadObject""" + + def __init__(self, model: type[Thread]): + self.model = model + + async def create(self, client: AsyncClient, thread_object: Thread) -> Thread | None: + """Create a new thread object.""" + data, _count = ( + await client.table("thread_objects") + .insert(thread_object.model_dump()) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def get(self, client: AsyncClient, thread_id: str) -> Thread | None: + """Get a thread object by its ID.""" + data, _count = ( + await client.table("thread_objects") + .select("*") + .eq("id", thread_id) + .execute() + ) + + _, response = data + + if data: + return self.model(**response[0]) + return None + + async def list(self, client: AsyncClient) -> list[Thread] | None: + """List all thread objects.""" + data, _count = await client.table("thread_objects").select("*").execute() + + _, response = data + + if response: + return [self.model(**item) for item in response] + return None + + async def update( + self, client: AsyncClient, thread_id: str, thread_object: Thread + ) -> Thread | None: + """Update a thread object by its ID.""" + data, _count = ( + await client.table("thread_objects") + .update(thread_object.model_dump()) + .eq("id", thread_id) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def delete(self, client: AsyncClient, thread_id: str) -> ThreadDeleted: + """Delete a thread object by its ID.""" + data, _count = ( + await client.table("thread_objects").delete().eq("id", thread_id).execute() + ) + + return ThreadDeleted(id=thread_id) diff --git a/src/leapfrogai_api/pyproject.toml b/src/leapfrogai_api/pyproject.toml index e257b790d..67c845c32 100644 --- a/src/leapfrogai_api/pyproject.toml +++ b/src/leapfrogai_api/pyproject.toml @@ -5,7 +5,7 @@ description = "An API for LeapfrogAI that allows LeapfrogAI backends to connect dependencies = [ "fastapi >= 0.109.1", - "openai >= 1.21.1", + "openai == 1.21.1", "uvicorn >= 0.23.2", "pydantic >= 2.0.0", "python-multipart >= 0.0.7", #indirect dep of FastAPI to receive form data for file uploads diff --git a/supabase/migrations/20240424005922_init-threads.sql b/supabase/migrations/20240424005922_init-threads.sql index 0915414c8..541e09b5c 100644 --- a/supabase/migrations/20240424005922_init-threads.sql +++ b/supabase/migrations/20240424005922_init-threads.sql @@ -3,19 +3,26 @@ create table thread_objects ( id uuid primary key DEFAULT uuid_generate_v4(), created_at bigint, - metadata jsonb + metadata jsonb, + object text, + tools_resources jsonb ); -- Create a table to store the OpenAI Message Objects create table message_objects ( id uuid primary key DEFAULT uuid_generate_v4(), + assistant_id uuid references assistant_objects(id) on delete set null, + attachments jsonb, + completed_at bigint, + content jsonb, created_at bigint, - thread_id uuid references thread_objects(id) on delete cascade, + incomplete_at bigint, + incomplete_details text, + metadata jsonb, + object text, role text, - content jsonb, - file_ids uuid[], - assistant_id uuid references assistant_objects(id) on delete set null, run_id uuid, - metadata jsonb + status text, + thread_id uuid references thread_objects(id) on delete cascade ); From 598151c3a16c2d9bc28c5f4c0a4db36965f40026 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 10:30:54 -0400 Subject: [PATCH 25/73] finishing up files --- src/leapfrogai_api/data/crud_file_bucket.py | 28 ++++++++++++++++ src/leapfrogai_api/routers/openai/files.py | 37 ++++++++++++++++----- 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 src/leapfrogai_api/data/crud_file_bucket.py diff --git a/src/leapfrogai_api/data/crud_file_bucket.py b/src/leapfrogai_api/data/crud_file_bucket.py new file mode 100644 index 000000000..9ba0e8505 --- /dev/null +++ b/src/leapfrogai_api/data/crud_file_bucket.py @@ -0,0 +1,28 @@ +"""CRUD Operations for the Files Bucket.""" + +from supabase_py_async import AsyncClient +from fastapi import UploadFile + + +class CRUDFileBucket: + """CRUD Operations for FileBucket.""" + + def __init__(self, model: type[UploadFile]): + self.model = model + + async def upload(self, client: AsyncClient, file: UploadFile, id_: str): + """Upload a file to the file bucket.""" + + return await client.storage.from_("file_bucket").upload( + file=file.file.read(), path=f"{id_}" + ) + + async def download(self, client: AsyncClient, id_: str): + """Get a file from the file bucket.""" + + return await client.storage.from_("file_bucket").download(path=f"{id_}") + + async def delete(self, client: AsyncClient, id_: str): + """Delete a file from the file bucket.""" + + return await client.storage.from_("file_bucket").remove(paths=f"{id_}") diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index e169535b9..24580932b 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -3,11 +3,12 @@ import time from uuid import uuid4 as uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile from openai.types import FileDeleted, FileObject from leapfrogai_api.backend.types import UploadFileRequest from leapfrogai_api.data.crud_file_object import CRUDFileObject +from leapfrogai_api.data.crud_file_bucket import CRUDFileBucket from leapfrogai_api.routers.supabase_session import Session router = APIRouter(prefix="/openai/v1/files", tags=["openai/files"]) @@ -19,7 +20,7 @@ async def upload_file( request: UploadFileRequest = Depends(UploadFileRequest.as_form), ) -> FileObject: """Upload a file.""" - # TODO: Store file in Supabase Storage + try: file_object = FileObject( id=str(uuid()), @@ -35,8 +36,14 @@ async def upload_file( raise HTTPException(status_code=500, detail="Failed to parse file") from exc try: - crud_file = CRUDFileObject(model=FileObject) - await crud_file.create(file_object=file_object, client=client) + crud_file_object = CRUDFileObject(model=FileObject) + await crud_file_object.create(file_object=file_object, client=client) + + crud_file_bucket = CRUDFileBucket(model=UploadFile) + await crud_file_bucket.upload( + client=client, file=request.file, id_=file_object.id + ) + except Exception as exc: raise HTTPException(status_code=500, detail="Failed to store file") from exc @@ -44,7 +51,7 @@ async def upload_file( @router.get("") -async def list_files(session: Session): +async def list_files(session: Session) -> list[FileObject] | None: """List all files.""" try: crud_file = CRUDFileObject(model=FileObject) @@ -71,8 +78,13 @@ async def retrieve_file(client: Session, file_id: str) -> FileObject: async def delete_file(session: Session, file_id: str) -> FileDeleted: """Delete a file.""" try: - crud_file = CRUDFileObject(model=FileObject) - return await crud_file.delete(file_id=file_id, client=session) + crud_file_object = CRUDFileObject(model=FileObject) + file_deleted = await crud_file_object.delete(file_id=file_id, client=session) + + crud_file_bucket = CRUDFileBucket(model=UploadFile) + await crud_file_bucket.delete(client=session, id_=file_id) + + return file_deleted except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="File not found") from exc @@ -80,5 +92,12 @@ async def delete_file(session: Session, file_id: str) -> FileDeleted: @router.get("/{file_id}/content") async def retrieve_file_content(session: Session, file_id: str): """Retrieve the content of a file.""" - # TODO: Retrieve file content from Supabase Storage - raise HTTPException(status_code=501, detail="Not implemented") + try: + crud_file_bucket = CRUDFileBucket(model=UploadFile) + return await crud_file_bucket.download(client=session, id_=file_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="File not found") from exc + except ValueError as exc: + raise HTTPException( + status_code=500, detail="Multiple files found with same id" + ) from exc From 9e8358ec86754eaeeaa05641a2dd92b3ac3ae5bb Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 10:40:00 -0400 Subject: [PATCH 26/73] remove unused utils --- src/leapfrogai_api/utils/openai_util.py | 29 +------------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/leapfrogai_api/utils/openai_util.py b/src/leapfrogai_api/utils/openai_util.py index 79220eef9..68fd3c05a 100644 --- a/src/leapfrogai_api/utils/openai_util.py +++ b/src/leapfrogai_api/utils/openai_util.py @@ -1,7 +1,6 @@ """This module contains utility functions for interacting with OpenAI API.""" -import logging -from typing import Dict, List, Union +from typing import Dict, List from openai.types.beta import ( CodeInterpreterTool, FileSearchTool, @@ -39,29 +38,3 @@ def validate_tools_typed_dict(data: List[Dict]) -> List[AssistantTool]: return tool_instance return [tool_instance] - - -def strings_to_tools(tool_names: Union[str, List[str]]) -> List[AssistantTool]: - """Convert a list of tool names to a list of tool instances.""" - tools = [] - included_types = set() # Set to track included tool types - - if isinstance(tool_names, str): - tool_names = [tool_names] - - for name in tool_names: - if name in tool_mapping and name not in included_types: - tool_class = tool_mapping[name] - tool_instance = tool_class(type=name) - tools.append(tool_instance) - included_types.add(name) # Mark this type as included - elif name not in tool_mapping: - logging.warning("Unknown tool type: %s", name) - raise ValueError(f"Unknown tool type: {name}") - - return tools - - -def tools_to_strings(tools: List[AssistantTool]) -> List[str]: - """Convert a list of tool instances to a list of tool names.""" - return [tool.type for tool in tools] From 57a4203aa92033598aa054cf9c634ebf43f5e5cc Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:17:27 -0400 Subject: [PATCH 27/73] removing threads/messages stuff (future PR) --- .../data/crud_message_object.py | 84 ------------------- src/leapfrogai_api/data/crud_thread_object.py | 75 ----------------- .../routers/openai/assistants.py | 8 +- .../20240424005922_init-threads.sql | 28 ------- 4 files changed, 2 insertions(+), 193 deletions(-) delete mode 100644 src/leapfrogai_api/data/crud_message_object.py delete mode 100644 src/leapfrogai_api/data/crud_thread_object.py delete mode 100644 supabase/migrations/20240424005922_init-threads.sql diff --git a/src/leapfrogai_api/data/crud_message_object.py b/src/leapfrogai_api/data/crud_message_object.py deleted file mode 100644 index 5a7478181..000000000 --- a/src/leapfrogai_api/data/crud_message_object.py +++ /dev/null @@ -1,84 +0,0 @@ -"""CRUD operations for the Message object.""" - -from supabase_py_async import AsyncClient -from openai.types.beta.threads import Message - - -class CRUDMessageObject: - """CRUD Operations for MessageObject""" - - def __init__(self, model: type[Message]): - self.model = model - - async def create( - self, client: AsyncClient, message_object: Message - ) -> Message | None: - """Create a new message object.""" - data, _count = ( - await client.table("message_objects") - .insert(message_object.model_dump()) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None - - async def get(self, client: AsyncClient, message_id: str) -> Message | None: - """Get a message object by its ID.""" - data, _count = ( - await client.table("message_objects") - .select("*") - .eq("id", message_id) - .execute() - ) - - _, response = data - - if data: - return self.model(**response[0]) - return None - - async def list(self, client: AsyncClient) -> list[Message] | None: - """List all message objects.""" - data, _count = await client.table("message_objects").select("*").execute() - - _, response = data - - if response: - return [self.model(**item) for item in response] - return None - - async def update( - self, client: AsyncClient, message_id: str, message_object: Message - ) -> Message | None: - """Update a message object by its ID.""" - data, _count = ( - await client.table("message_objects") - .update(message_object.model_dump()) - .eq("id", message_id) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None - - async def delete(self, client: AsyncClient, message_id: str) -> Message | None: - """Delete a message object by its ID.""" - data, _count = ( - await client.table("message_objects") - .delete() - .eq("id", message_id) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None diff --git a/src/leapfrogai_api/data/crud_thread_object.py b/src/leapfrogai_api/data/crud_thread_object.py deleted file mode 100644 index 88ef88050..000000000 --- a/src/leapfrogai_api/data/crud_thread_object.py +++ /dev/null @@ -1,75 +0,0 @@ -"""CRUD Operations for ThreadObject.""" - -from supabase_py_async import AsyncClient -from openai.types.beta import Thread, ThreadDeleted - - -class CRUDThreadObject: - """CRUD Operations for ThreadObject""" - - def __init__(self, model: type[Thread]): - self.model = model - - async def create(self, client: AsyncClient, thread_object: Thread) -> Thread | None: - """Create a new thread object.""" - data, _count = ( - await client.table("thread_objects") - .insert(thread_object.model_dump()) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None - - async def get(self, client: AsyncClient, thread_id: str) -> Thread | None: - """Get a thread object by its ID.""" - data, _count = ( - await client.table("thread_objects") - .select("*") - .eq("id", thread_id) - .execute() - ) - - _, response = data - - if data: - return self.model(**response[0]) - return None - - async def list(self, client: AsyncClient) -> list[Thread] | None: - """List all thread objects.""" - data, _count = await client.table("thread_objects").select("*").execute() - - _, response = data - - if response: - return [self.model(**item) for item in response] - return None - - async def update( - self, client: AsyncClient, thread_id: str, thread_object: Thread - ) -> Thread | None: - """Update a thread object by its ID.""" - data, _count = ( - await client.table("thread_objects") - .update(thread_object.model_dump()) - .eq("id", thread_id) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None - - async def delete(self, client: AsyncClient, thread_id: str) -> ThreadDeleted: - """Delete a thread object by its ID.""" - data, _count = ( - await client.table("thread_objects").delete().eq("id", thread_id).execute() - ) - - return ThreadDeleted(id=thread_id) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index c8fee0f04..87d9d2976 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -2,7 +2,6 @@ import time from typing import List -from uuid import uuid4 from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources @@ -24,12 +23,9 @@ async def create_assistant( """Create an assistant.""" try: - created_at = int(time.time()) - assistant_id = str(uuid4()) - assistant = Assistant( - id=assistant_id, - created_at=created_at, + id="", + created_at=int(time.time()), name=request.name, description=request.description, instructions=request.instructions, diff --git a/supabase/migrations/20240424005922_init-threads.sql b/supabase/migrations/20240424005922_init-threads.sql deleted file mode 100644 index 541e09b5c..000000000 --- a/supabase/migrations/20240424005922_init-threads.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Create a table to store the OpenAI Thread Objects -create table - thread_objects ( - id uuid primary key DEFAULT uuid_generate_v4(), - created_at bigint, - metadata jsonb, - object text, - tools_resources jsonb - ); - --- Create a table to store the OpenAI Message Objects -create table - message_objects ( - id uuid primary key DEFAULT uuid_generate_v4(), - assistant_id uuid references assistant_objects(id) on delete set null, - attachments jsonb, - completed_at bigint, - content jsonb, - created_at bigint, - incomplete_at bigint, - incomplete_details text, - metadata jsonb, - object text, - role text, - run_id uuid, - status text, - thread_id uuid references thread_objects(id) on delete cascade - ); From f20c3d4931412f672ad7b07b8282ba30cd202839 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:18:45 -0400 Subject: [PATCH 28/73] remove env.example --- src/leapfrogai_api/.env.example | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 src/leapfrogai_api/.env.example diff --git a/src/leapfrogai_api/.env.example b/src/leapfrogai_api/.env.example deleted file mode 100644 index 58dc54c81..000000000 --- a/src/leapfrogai_api/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -SUPABASE_KEY="" -SUPABASE_URL="http://localhost:54321" From 42f18e49260928a0995d83e7b82738131b6fd2a7 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:21:39 -0400 Subject: [PATCH 29/73] remove Supabase deployment variables (future PR) --- packages/api/chart/templates/api/deployment.yaml | 7 ------- packages/api/chart/values.yaml | 1 - packages/api/zarf.yaml | 7 ------- 3 files changed, 15 deletions(-) diff --git a/packages/api/chart/templates/api/deployment.yaml b/packages/api/chart/templates/api/deployment.yaml index 2724023a3..b8a4097bd 100644 --- a/packages/api/chart/templates/api/deployment.yaml +++ b/packages/api/chart/templates/api/deployment.yaml @@ -45,13 +45,6 @@ spec: value: "*.toml" - name: PORT value: "{{ .Values.api.port }}" - - name: SUPABASE_URL - value: "{{ .Values.package.supabase_url }}" - # - name: SUPABASE_KEY - # valueFrom: - # secretKeyRef: - # name: supabase-jwt - # key: anon-key ports: - containerPort: 8080 livenessProbe: diff --git a/packages/api/chart/values.yaml b/packages/api/chart/values.yaml index bec1262e3..529930905 100644 --- a/packages/api/chart/values.yaml +++ b/packages/api/chart/values.yaml @@ -8,4 +8,3 @@ api: package: host: leapfrogai-api - supabase_url: '###ZARF_VAR_SUPABASE_URL###' diff --git a/packages/api/zarf.yaml b/packages/api/zarf.yaml index f4be40f50..79ae224c7 100644 --- a/packages/api/zarf.yaml +++ b/packages/api/zarf.yaml @@ -14,13 +14,6 @@ constants: - name: KIWIGRID_VERSION value: "1.23.3" -variables: - - name: SUPABASE_URL - description: URL for supabase - prompt: true - default: supabase-kong.leapfrogai.svc.cluster.local:8080 - sensitive: false - components: - name: leapfrogai required: true From 12260629d5a074e0a0c03480dc323990d0311e4b Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:39:56 -0400 Subject: [PATCH 30/73] ironing out remaining issues with assistant crud --- .../data/crud_assistant_object.py | 7 +++- .../routers/openai/assistants.py | 38 +++++++++++++++++-- .../20240419164109_init-assistant.sql | 2 +- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/leapfrogai_api/data/crud_assistant_object.py b/src/leapfrogai_api/data/crud_assistant_object.py index 94a5049b9..8c37932c7 100644 --- a/src/leapfrogai_api/data/crud_assistant_object.py +++ b/src/leapfrogai_api/data/crud_assistant_object.py @@ -14,9 +14,12 @@ async def create( self, client: AsyncClient, assistant: Assistant ) -> Assistant | None: """Create a new assistant.""" + assistant_object_dict = assistant.model_dump() + if assistant_object_dict.get("id") == "": + del assistant_object_dict["id"] data, _count = ( await client.table("assistant_objects") - .insert(assistant.model_dump()) + .insert(assistant_object_dict) .execute() ) @@ -37,7 +40,7 @@ async def get(self, client: AsyncClient, assistant_id: str) -> Assistant | None: _, response = data - if data: + if response: return self.model(**response[0]) return None diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 87d9d2976..19271649e 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -38,10 +38,14 @@ async def create_assistant( metadata=request.metadata, response_format=request.response_format, ) + except Exception as exc: + raise HTTPException( + status_code=405, detail="Unable to parse assistant request" + ) from exc + try: crud_assistant = CRUDAssistant(model=Assistant) return await crud_assistant.create(assistant=assistant, client=session) - except Exception as exc: raise HTTPException( status_code=405, detail="Unable to create assistant" @@ -59,7 +63,7 @@ async def list_assistants(session: Session) -> List[Assistant] | None: @router.get("/{assistant_id}") -async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant: +async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | None: """Retrieve an assistant.""" try: crud_assistant = CRUDAssistant(model=Assistant) @@ -73,8 +77,34 @@ async def modify_assistant( session: Session, assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" - # TODO: Implement this function - raise HTTPException(status_code=405, detail="Not Implemented") + try: + assistant = Assistant( + id=assistant_id, + created_at=int(time.time()), + name=request.name, + description=request.description, + instructions=request.instructions, + model=request.model, + object="assistant", + tools=validate_tools_typed_dict(request.tools), + tool_resources=ToolResources.model_validate(request.tool_resources), + temperature=request.temperature, + top_p=request.top_p, + metadata=request.metadata, + response_format=request.response_format, + ) + except Exception as exc: + raise HTTPException( + status_code=405, detail="Unable to parse assistant request" + ) from exc + + try: + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.update( + assistant_id=assistant_id, assistant=assistant, client=session + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Assistant not found") from exc @router.delete("/{assistant_id}") diff --git a/supabase/migrations/20240419164109_init-assistant.sql b/supabase/migrations/20240419164109_init-assistant.sql index 594af36cb..cb3943ac7 100644 --- a/supabase/migrations/20240419164109_init-assistant.sql +++ b/supabase/migrations/20240419164109_init-assistant.sql @@ -1,7 +1,7 @@ -- Create a table to store OpenAI Assistant Objects create table assistant_objects ( - id uuid primary key, + id uuid primary key DEFAULT uuid_generate_v4(), created_at bigint, description text, instructions text, From ebf1ef2f30f7446033f20bcfb4ba0ff12d2561f8 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:40:53 -0400 Subject: [PATCH 31/73] update test to include assistants --- tests/pytest/leapfrogai_api/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/pytest/leapfrogai_api/test_api.py b/tests/pytest/leapfrogai_api/test_api.py index 85864d414..29ca44db6 100644 --- a/tests/pytest/leapfrogai_api/test_api.py +++ b/tests/pytest/leapfrogai_api/test_api.py @@ -74,6 +74,7 @@ def test_routes(): "/openai/v1/embeddings": ["POST"], "/openai/v1/audio/transcriptions": ["POST"], "/openai/v1/files": ["POST"], + "/openai/v1/assistants": ["POST"], } actual_routes = app.routes From 08605f4ece22489e44ad2516c5236f1bcc3b66e0 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:46:08 -0400 Subject: [PATCH 32/73] add a comment about the uuid --- src/leapfrogai_api/routers/openai/assistants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 19271649e..27728da19 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -24,7 +24,7 @@ async def create_assistant( try: assistant = Assistant( - id="", + id="", # Leave blank to have Postgres generate a UUID created_at=int(time.time()), name=request.name, description=request.description, From 66db8aabe34285664eb553b14067107a7f88c1c7 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 15:47:05 -0400 Subject: [PATCH 33/73] thank god for linters... --- src/leapfrogai_api/routers/openai/assistants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 27728da19..7e2de825d 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -24,7 +24,7 @@ async def create_assistant( try: assistant = Assistant( - id="", # Leave blank to have Postgres generate a UUID + id="", # Leave blank to have Postgres generate a UUID created_at=int(time.time()), name=request.name, description=request.description, From 762a5dcd28d2abad38cd48dcd9f8b3c0adf35e9d Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 17:33:58 -0400 Subject: [PATCH 34/73] add UUID4 type validation to endpoints --- src/leapfrogai_api/routers/openai/assistants.py | 7 ++++--- src/leapfrogai_api/routers/openai/files.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 7e2de825d..88d9e0202 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -5,6 +5,7 @@ from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources +from pydantic import UUID4 from leapfrogai_api.backend.types import ( CreateAssistantRequest, ModifyAssistantRequest, @@ -63,7 +64,7 @@ async def list_assistants(session: Session) -> List[Assistant] | None: @router.get("/{assistant_id}") -async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | None: +async def retrieve_assistant(session: Session, assistant_id: UUID4) -> Assistant | None: """Retrieve an assistant.""" try: crud_assistant = CRUDAssistant(model=Assistant) @@ -74,7 +75,7 @@ async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | @router.post("/{assistant_id}") async def modify_assistant( - session: Session, assistant_id: str, request: ModifyAssistantRequest + session: Session, assistant_id: UUID4, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" try: @@ -108,7 +109,7 @@ async def modify_assistant( @router.delete("/{assistant_id}") -async def delete_assistant(session: Session, assistant_id: str) -> AssistantDeleted: +async def delete_assistant(session: Session, assistant_id: UUID4) -> AssistantDeleted: """Delete an assistant.""" try: crud_assistant = CRUDAssistant(model=Assistant) diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 973d34e12..529bd6322 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile from openai.types import FileDeleted, FileObject +from pydantic import UUID4 from leapfrogai_api.backend.types import UploadFileRequest from leapfrogai_api.data.crud_file_object import CRUDFileObject @@ -65,7 +66,7 @@ async def list_files(session: Session) -> list[FileObject] | None: @router.get("/{file_id}") -async def retrieve_file(client: Session, file_id: str) -> FileObject | None: +async def retrieve_file(client: Session, file_id: UUID4) -> FileObject | None: """Retrieve a file.""" try: crud_file = CRUDFileObject(model=FileObject) @@ -79,7 +80,7 @@ async def retrieve_file(client: Session, file_id: str) -> FileObject | None: @router.delete("/{file_id}") -async def delete_file(session: Session, file_id: str) -> FileDeleted: +async def delete_file(session: Session, file_id: UUID4) -> FileDeleted: """Delete a file.""" try: crud_file_object = CRUDFileObject(model=FileObject) @@ -94,7 +95,7 @@ async def delete_file(session: Session, file_id: str) -> FileDeleted: @router.get("/{file_id}/content") -async def retrieve_file_content(session: Session, file_id: str): +async def retrieve_file_content(session: Session, file_id: UUID4): """Retrieve the content of a file.""" try: crud_file_bucket = CRUDFileBucket(model=UploadFile) From 503f4f3ccf8d3b44c71310baf86da9824da24506 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 1 May 2024 18:15:24 -0400 Subject: [PATCH 35/73] adding back tests, but they still require Supabase connection --- .../routers/openai/assistants.py | 7 +- src/leapfrogai_api/routers/openai/files.py | 7 +- .../pytest/leapfrogai_api/test_assistants.py | 76 +++++++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 tests/pytest/leapfrogai_api/test_assistants.py diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 88d9e0202..7e2de825d 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -5,7 +5,6 @@ from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources -from pydantic import UUID4 from leapfrogai_api.backend.types import ( CreateAssistantRequest, ModifyAssistantRequest, @@ -64,7 +63,7 @@ async def list_assistants(session: Session) -> List[Assistant] | None: @router.get("/{assistant_id}") -async def retrieve_assistant(session: Session, assistant_id: UUID4) -> Assistant | None: +async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | None: """Retrieve an assistant.""" try: crud_assistant = CRUDAssistant(model=Assistant) @@ -75,7 +74,7 @@ async def retrieve_assistant(session: Session, assistant_id: UUID4) -> Assistant @router.post("/{assistant_id}") async def modify_assistant( - session: Session, assistant_id: UUID4, request: ModifyAssistantRequest + session: Session, assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" try: @@ -109,7 +108,7 @@ async def modify_assistant( @router.delete("/{assistant_id}") -async def delete_assistant(session: Session, assistant_id: UUID4) -> AssistantDeleted: +async def delete_assistant(session: Session, assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" try: crud_assistant = CRUDAssistant(model=Assistant) diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 529bd6322..973d34e12 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile from openai.types import FileDeleted, FileObject -from pydantic import UUID4 from leapfrogai_api.backend.types import UploadFileRequest from leapfrogai_api.data.crud_file_object import CRUDFileObject @@ -66,7 +65,7 @@ async def list_files(session: Session) -> list[FileObject] | None: @router.get("/{file_id}") -async def retrieve_file(client: Session, file_id: UUID4) -> FileObject | None: +async def retrieve_file(client: Session, file_id: str) -> FileObject | None: """Retrieve a file.""" try: crud_file = CRUDFileObject(model=FileObject) @@ -80,7 +79,7 @@ async def retrieve_file(client: Session, file_id: UUID4) -> FileObject | None: @router.delete("/{file_id}") -async def delete_file(session: Session, file_id: UUID4) -> FileDeleted: +async def delete_file(session: Session, file_id: str) -> FileDeleted: """Delete a file.""" try: crud_file_object = CRUDFileObject(model=FileObject) @@ -95,7 +94,7 @@ async def delete_file(session: Session, file_id: UUID4) -> FileDeleted: @router.get("/{file_id}/content") -async def retrieve_file_content(session: Session, file_id: UUID4): +async def retrieve_file_content(session: Session, file_id: str): """Retrieve the content of a file.""" try: crud_file_bucket = CRUDFileBucket(model=UploadFile) diff --git a/tests/pytest/leapfrogai_api/test_assistants.py b/tests/pytest/leapfrogai_api/test_assistants.py new file mode 100644 index 000000000..9b53461cc --- /dev/null +++ b/tests/pytest/leapfrogai_api/test_assistants.py @@ -0,0 +1,76 @@ +""" Test the API endpoints for assistants. """ +from fastapi.testclient import TestClient +from openai.types.beta import Assistant + +from leapfrogai_api.routers.openai.assistants import router +from leapfrogai_api.backend.types import ( + CreateAssistantRequest, + ModifyAssistantRequest, +) + +client = TestClient(router) + +# TODO: Mock Supabase session + +def test_assistants(): + """Test creating an assistant.""" + request = CreateAssistantRequest( + model="test", + name="test", + description="test", + instructions="test", + tools=[{"type": "file_search"}], + tool_resources={}, + metadata={}, + temperature=1.0, + top_p=1.0, + response_format="auto", + ) + + create_response = client.post("/openai/v1/assistants", json=request.model_dump()) + assert create_response.status_code == 200 + assert Assistant.model_validate(create_response.json()) + + list_response = client.get("/openai/v1/assistants") + assert list_response.status_code == 200 + assert Assistant.model_validate(list_response.json()[0]) + + get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") + assert get_response.status_code == 200 + + request = ModifyAssistantRequest( + model="test1", + name="test1", + description="test1", + instructions="test1", + tools=[{"type": "file_search"}], + tool_resources={}, + metadata={}, + temperature=1.0, + top_p=1.0, + response_format="auto", + ) + + modify_response = client.post( + f"/openai/v1/assistants/{create_response.json()['id']}", + json=request.model_dump(), + ) + assert modify_response.status_code == 200 + assert Assistant.model_validate(modify_response.json()) + + get_modified_response = client.get( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert get_modified_response.status_code == 200 + + delete_response = client.delete( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert delete_response.status_code == 200 + + # Make sure the assistant is not still present + retrieve_assistant_response = client.get( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert retrieve_assistant_response.status_code == 200 + assert retrieve_assistant_response.json() is None From 6758a3fcbce14524596f609376461dc1101de99e Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 2 May 2024 12:00:46 -0400 Subject: [PATCH 36/73] working on integration tests --- src/leapfrogai_api/routers/openai/files.py | 12 ++++- tests/data/test.txt | 1 + .../test_assistants.py | 6 +-- tests/integration/test_files.py | 44 +++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/data/test.txt rename tests/{pytest/leapfrogai_api => integration}/test_assistants.py (94%) create mode 100644 tests/integration/test_files.py diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 973d34e12..4d12a3d1f 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -43,13 +43,21 @@ async def upload_file( if not file_object: raise HTTPException(status_code=500, detail="Failed to create file object") + except Exception as exc: + raise HTTPException( + status_code=500, detail="Failed to store file object" + ) from exc + + try: crud_file_bucket = CRUDFileBucket(model=UploadFile) await crud_file_bucket.upload( client=client, file=request.file, id_=file_object.id ) - except Exception as exc: - raise HTTPException(status_code=500, detail="Failed to store file") from exc + crud_file_object.delete(file_id=file_object.id, client=client) + raise HTTPException( + status_code=500, detail="Failed to store file in bucket" + ) from exc return file_object diff --git a/tests/data/test.txt b/tests/data/test.txt new file mode 100644 index 000000000..0a9012568 --- /dev/null +++ b/tests/data/test.txt @@ -0,0 +1 @@ +Testing \ No newline at end of file diff --git a/tests/pytest/leapfrogai_api/test_assistants.py b/tests/integration/test_assistants.py similarity index 94% rename from tests/pytest/leapfrogai_api/test_assistants.py rename to tests/integration/test_assistants.py index 9b53461cc..adf2af590 100644 --- a/tests/pytest/leapfrogai_api/test_assistants.py +++ b/tests/integration/test_assistants.py @@ -1,4 +1,5 @@ -""" Test the API endpoints for assistants. """ +"""Test the API endpoints for assistants.""" + from fastapi.testclient import TestClient from openai.types.beta import Assistant @@ -10,10 +11,9 @@ client = TestClient(router) -# TODO: Mock Supabase session def test_assistants(): - """Test creating an assistant.""" + """Test creating an assistant. Requires a running Supabase instance.""" request = CreateAssistantRequest( model="test", name="test", diff --git a/tests/integration/test_files.py b/tests/integration/test_files.py new file mode 100644 index 000000000..7dd4f5dea --- /dev/null +++ b/tests/integration/test_files.py @@ -0,0 +1,44 @@ +"""Test the API endpoints for assistants.""" + +from fastapi.testclient import TestClient +from openai.types import FileObject, FileDeleted +from leapfrogai_api.routers.openai.files import router + +client = TestClient(router) + + +def test_files(): + """Test creating an assistant. Requires a running Supabase instance.""" + + with open("tests/data/test.txt", "rb") as testfile: + testfile_content = testfile.read() + create_response = client.post( + "/openai/v1/files", + files={"file": ("test.txt", testfile, "text/plain")}, + data={"purpose": "assistants"}, + ) + + assert create_response.status_code == 200 + assert FileObject.model_validate(create_response.json()) + + list_response = client.get("/openai/v1/files") + assert list_response.status_code == 200 + assert FileObject.model_validate(list_response.json()[0]) + + get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") + assert get_response.status_code == 200 + + get_content_response = client.get( + f"/openai/v1/files/{create_response.json()['id']}/content" + ) + assert get_content_response.status_code == 200 + assert testfile_content.decode() in get_content_response.text + + delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") + assert delete_response.status_code == 200 + assert FileDeleted.model_validate(delete_response.json()) + + # Make sure the assistant is not still present + get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") + assert get_response.status_code == 200 + assert get_response.json() is None From 31de889ae291b705ce72bd07c774e3c6ee73f959 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 2 May 2024 14:25:13 -0400 Subject: [PATCH 37/73] improving integration tests --- tests/integration/test_assistants.py | 34 +++++++++++++++++++++++++--- tests/integration/test_files.py | 26 ++++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_assistants.py b/tests/integration/test_assistants.py index adf2af590..9bcaf46ef 100644 --- a/tests/integration/test_assistants.py +++ b/tests/integration/test_assistants.py @@ -1,7 +1,7 @@ """Test the API endpoints for assistants.""" from fastapi.testclient import TestClient -from openai.types.beta import Assistant +from openai.types.beta import Assistant, AssistantDeleted from leapfrogai_api.routers.openai.assistants import router from leapfrogai_api.backend.types import ( @@ -33,10 +33,14 @@ def test_assistants(): list_response = client.get("/openai/v1/assistants") assert list_response.status_code == 200 - assert Assistant.model_validate(list_response.json()[0]) + for assistant_object in list_response.json(): + assert Assistant.model_validate( + assistant_object + ), "Should return a list of Assistants." get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") assert get_response.status_code == 200 + assert Assistant.model_validate(get_response.json()), "Should return an Assistant." request = ModifyAssistantRequest( model="test1", @@ -56,17 +60,41 @@ def test_assistants(): json=request.model_dump(), ) assert modify_response.status_code == 200 - assert Assistant.model_validate(modify_response.json()) + assert Assistant.model_validate( + modify_response.json() + ), "Should return a Assistant." + assert modify_response.json()["model"] == "test1", "Should be modified." get_modified_response = client.get( f"/openai/v1/assistants/{create_response.json()['id']}" ) assert get_modified_response.status_code == 200 + assert Assistant.model_validate( + get_modified_response.json() + ), "Should return a Assistant." + assert get_modified_response.json()["model"] == "test1", "Should be modified." delete_response = client.delete( f"/openai/v1/assistants/{create_response.json()['id']}" ) assert delete_response.status_code == 200 + assert AssistantDeleted.model_validate( + delete_response.json() + ), "Should return a AssistantDeleted object." + assert delete_response.json()["deleted"] is True, "Should be able to delete." + + delete_response = client.delete( + f"/openai/v1/assistants/{create_response.json()['id']}" + ) + assert ( + delete_response.status_code == 200 + ), "Should return 200 even if the assistant is not found." + assert AssistantDeleted.model_validate( + delete_response.json() + ), "Should return a AssistantDeleted object." + assert ( + delete_response.json()["deleted"] is False + ), "Should not be able to delete twice." # Make sure the assistant is not still present retrieve_assistant_response = client.get( diff --git a/tests/integration/test_files.py b/tests/integration/test_files.py index 7dd4f5dea..b9bbcd834 100644 --- a/tests/integration/test_files.py +++ b/tests/integration/test_files.py @@ -23,20 +23,40 @@ def test_files(): list_response = client.get("/openai/v1/files") assert list_response.status_code == 200 - assert FileObject.model_validate(list_response.json()[0]) + for file_object in list_response.json(): + assert FileObject.model_validate( + file_object + ), "Should return a list of FileObjects." get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") assert get_response.status_code == 200 + assert FileObject.model_validate(get_response.json()), "Should return a FileObject." get_content_response = client.get( f"/openai/v1/files/{create_response.json()['id']}/content" ) assert get_content_response.status_code == 200 - assert testfile_content.decode() in get_content_response.text + assert ( + testfile_content.decode() in get_content_response.text + ), "Should return the file content." delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") assert delete_response.status_code == 200 - assert FileDeleted.model_validate(delete_response.json()) + assert FileDeleted.model_validate( + delete_response.json() + ), "Should return a FileDeleted object." + assert delete_response.json()["deleted"] is True, "Should be able to delete." + + delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") + assert ( + delete_response.status_code == 200 + ), "Should return 200 even if the file is not found." + assert FileDeleted.model_validate( + delete_response.json() + ), "Should return a FileDeleted object." + assert ( + delete_response.json()["deleted"] is False + ), "Should not be able to delete twice." # Make sure the assistant is not still present get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") From 82a7d4a34e72abae8d5ebd81d6134a3b7a346237 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 2 May 2024 14:43:33 -0400 Subject: [PATCH 38/73] documenting integration tests --- src/leapfrogai_api/Makefile | 3 +++ src/leapfrogai_api/README.md | 19 ++++++++++++++++++- .../integration/{ => api}/test_assistants.py | 0 tests/integration/{ => api}/test_files.py | 0 4 files changed, 21 insertions(+), 1 deletion(-) rename tests/integration/{ => api}/test_assistants.py (100%) rename tests/integration/{ => api}/test_files.py (100%) diff --git a/src/leapfrogai_api/Makefile b/src/leapfrogai_api/Makefile index 185c71526..4419f9050 100644 --- a/src/leapfrogai_api/Makefile +++ b/src/leapfrogai_api/Makefile @@ -4,3 +4,6 @@ install: dev: make install python -m uvicorn main:app --port 3000 --reload + +test-integration: + cd ../../ && python -m pytest tests/integration/api diff --git a/src/leapfrogai_api/README.md b/src/leapfrogai_api/README.md index 32feba67b..52a4efc40 100644 --- a/src/leapfrogai_api/README.md +++ b/src/leapfrogai_api/README.md @@ -23,4 +23,21 @@ Setup environment variables: ``` bash export SUPABASE_URL="http://localhost:54321" # or whatever you configured it as in your Supabase config.toml export SUPABASE_SERVICE_KEY="" # supabase status will show you the keys -``` \ No newline at end of file +``` + +## Integration Tests + +The integration tests serve to identify any mismatches between components: + +- Check all API routes +- Validate Request/Response types +- DB CRUD operations +- Schema mismatches + +Integration tests require a Supbase instance and environment variables configured. + +From this directory run: + +``` bash +make test-integration +``` diff --git a/tests/integration/test_assistants.py b/tests/integration/api/test_assistants.py similarity index 100% rename from tests/integration/test_assistants.py rename to tests/integration/api/test_assistants.py diff --git a/tests/integration/test_files.py b/tests/integration/api/test_files.py similarity index 100% rename from tests/integration/test_files.py rename to tests/integration/api/test_files.py From c1dc712d0c2fa2b3104bdbb60b210e1dc6164e10 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Fri, 3 May 2024 11:21:35 -0400 Subject: [PATCH 39/73] tweaking some naming and comments --- tests/integration/api/test_assistants.py | 8 +++----- tests/integration/api/test_files.py | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index 9bcaf46ef..f2f52b0b4 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -97,8 +97,6 @@ def test_assistants(): ), "Should not be able to delete twice." # Make sure the assistant is not still present - retrieve_assistant_response = client.get( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) - assert retrieve_assistant_response.status_code == 200 - assert retrieve_assistant_response.json() is None + get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") + assert get_response.status_code == 200 + assert get_response.json() is None diff --git a/tests/integration/api/test_files.py b/tests/integration/api/test_files.py index b9bbcd834..33c91ea06 100644 --- a/tests/integration/api/test_files.py +++ b/tests/integration/api/test_files.py @@ -1,4 +1,4 @@ -"""Test the API endpoints for assistants.""" +"""Test the API endpoints for files.""" from fastapi.testclient import TestClient from openai.types import FileObject, FileDeleted @@ -8,7 +8,7 @@ def test_files(): - """Test creating an assistant. Requires a running Supabase instance.""" + """Test creating a file. Requires a running Supabase instance.""" with open("tests/data/test.txt", "rb") as testfile: testfile_content = testfile.read() @@ -58,7 +58,7 @@ def test_files(): delete_response.json()["deleted"] is False ), "Should not be able to delete twice." - # Make sure the assistant is not still present + # Make sure the file is not still present get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") assert get_response.status_code == 200 assert get_response.json() is None From 8ac7f64b4307e6e4556e3029329d11df5f5d86dc Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 6 May 2024 01:13:48 -0400 Subject: [PATCH 40/73] fixing list files and list assistants endpoints --- src/leapfrogai_api/routers/openai/assistants.py | 10 +++++++--- src/leapfrogai_api/routers/openai/files.py | 11 +++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 7e2de825d..e6f21f58a 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -1,7 +1,6 @@ """OpenAI Compliant Assistants API Router.""" import time -from typing import List from fastapi import HTTPException, APIRouter from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources @@ -53,11 +52,16 @@ async def create_assistant( @router.get("") -async def list_assistants(session: Session) -> List[Assistant] | None: +async def list_assistants(session: Session): """List all the assistants.""" try: crud_assistant = CRUDAssistant(model=Assistant) - return await crud_assistant.list(client=session) + crud_response = await crud_assistant.list(client=session) + response = { + "object": "list", + "data": crud_response, + } + return response except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="No assistants found") from exc diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 4d12a3d1f..470b1ff06 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -1,10 +1,8 @@ """OpenAI Compliant Files API Router.""" import time - from fastapi import APIRouter, Depends, HTTPException, UploadFile from openai.types import FileDeleted, FileObject - from leapfrogai_api.backend.types import UploadFileRequest from leapfrogai_api.data.crud_file_object import CRUDFileObject from leapfrogai_api.data.crud_file_bucket import CRUDFileBucket @@ -63,11 +61,16 @@ async def upload_file( @router.get("") -async def list_files(session: Session) -> list[FileObject] | None: +async def list_files(session: Session): """List all files.""" try: crud_file = CRUDFileObject(model=FileObject) - return await crud_file.list(client=session) + crud_response = await crud_file.list(client=session) + response = { + "object": "list", + "data": crud_response, + } + return response except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="No file objects found") from exc From 32898bac662f1214e3c140d39cd851d311aa3100 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 6 May 2024 01:17:21 -0400 Subject: [PATCH 41/73] update tests --- tests/integration/api/test_assistants.py | 2 +- tests/integration/api/test_files.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index f2f52b0b4..35bd61c4b 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -33,7 +33,7 @@ def test_assistants(): list_response = client.get("/openai/v1/assistants") assert list_response.status_code == 200 - for assistant_object in list_response.json(): + for assistant_object in list_response.json()["data"]: assert Assistant.model_validate( assistant_object ), "Should return a list of Assistants." diff --git a/tests/integration/api/test_files.py b/tests/integration/api/test_files.py index 33c91ea06..46f7c650b 100644 --- a/tests/integration/api/test_files.py +++ b/tests/integration/api/test_files.py @@ -23,7 +23,7 @@ def test_files(): list_response = client.get("/openai/v1/files") assert list_response.status_code == 200 - for file_object in list_response.json(): + for file_object in list_response.json()["data"]: assert FileObject.model_validate( file_object ), "Should return a list of FileObjects." From 7f51af915386922a7b1eb24ab4a0157f89addfae Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 6 May 2024 11:24:20 -0400 Subject: [PATCH 42/73] improve list type hints and modify assistants --- src/leapfrogai_api/backend/types.py | 16 ++++++++++ .../routers/openai/assistants.py | 31 ++++++++++++------- src/leapfrogai_api/routers/openai/files.py | 4 +-- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/leapfrogai_api/backend/types.py b/src/leapfrogai_api/backend/types.py index 7b35239b4..1cfedf1e3 100644 --- a/src/leapfrogai_api/backend/types.py +++ b/src/leapfrogai_api/backend/types.py @@ -4,6 +4,8 @@ from typing import Literal from pydantic import BaseModel from fastapi import UploadFile, Form, File +from openai.types import FileObject +from openai.types.beta import Assistant ########## # GENERIC @@ -229,6 +231,13 @@ def as_form( return cls(file=file, purpose=purpose) +class ListFilesResponse(BaseModel): + """Response object for listing files.""" + + object: str = "list" + data: list[FileObject] = [] + + ############# # ASSISTANTS ############# @@ -253,3 +262,10 @@ class CreateAssistantRequest(BaseModel): class ModifyAssistantRequest(CreateAssistantRequest): """Request object for modifying an assistant.""" + + +class ListAssistantsResponse(BaseModel): + """Response object for listing files.""" + + object: str = "list" + data: list[Assistant] = [] diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index e6f21f58a..96aa18f8e 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -6,6 +6,7 @@ from openai.types.beta.assistant import ToolResources from leapfrogai_api.backend.types import ( CreateAssistantRequest, + ListAssistantsResponse, ModifyAssistantRequest, ) from leapfrogai_api.routers.supabase_session import Session @@ -52,7 +53,7 @@ async def create_assistant( @router.get("") -async def list_assistants(session: Session): +async def list_assistants(session: Session) -> ListAssistantsResponse | None: """List all the assistants.""" try: crud_assistant = CRUDAssistant(model=Assistant) @@ -81,21 +82,27 @@ async def modify_assistant( session: Session, assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" + + old_assistant = await retrieve_assistant(session, assistant_id) + if old_assistant is None: + raise HTTPException(status_code=404, detail="Assistant not found") + try: assistant = Assistant( id=assistant_id, - created_at=int(time.time()), - name=request.name, - description=request.description, - instructions=request.instructions, - model=request.model, + created_at=old_assistant.created_at, + name=request.name or old_assistant.name, + description=request.description or old_assistant.description, + instructions=request.instructions or old_assistant.instructions, + model=request.model or old_assistant.model, object="assistant", - tools=validate_tools_typed_dict(request.tools), - tool_resources=ToolResources.model_validate(request.tool_resources), - temperature=request.temperature, - top_p=request.top_p, - metadata=request.metadata, - response_format=request.response_format, + tools=validate_tools_typed_dict(request.tools) or old_assistant.tools, + tool_resources=ToolResources.model_validate(request.tool_resources) + or old_assistant.tool_resources, + temperature=request.temperature or old_assistant.temperature, + top_p=request.top_p or old_assistant.top_p, + metadata=request.metadata or old_assistant.metadata, + response_format=request.response_format or old_assistant.response_format, ) except Exception as exc: raise HTTPException( diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 470b1ff06..ea49ff26f 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -3,7 +3,7 @@ import time from fastapi import APIRouter, Depends, HTTPException, UploadFile from openai.types import FileDeleted, FileObject -from leapfrogai_api.backend.types import UploadFileRequest +from leapfrogai_api.backend.types import ListFilesResponse, UploadFileRequest from leapfrogai_api.data.crud_file_object import CRUDFileObject from leapfrogai_api.data.crud_file_bucket import CRUDFileBucket from leapfrogai_api.routers.supabase_session import Session @@ -61,7 +61,7 @@ async def upload_file( @router.get("") -async def list_files(session: Session): +async def list_files(session: Session) -> ListFilesResponse | None: """List all files.""" try: crud_file = CRUDFileObject(model=FileObject) From ad27579645d0acb6c8b9733dc710a4e21ec6ca35 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 6 May 2024 11:30:04 -0400 Subject: [PATCH 43/73] handle errors in list endpoints --- .../routers/openai/assistants.py | 20 +++++++++---------- src/leapfrogai_api/routers/openai/files.py | 20 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 96aa18f8e..1f9131949 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -55,16 +55,16 @@ async def create_assistant( @router.get("") async def list_assistants(session: Session) -> ListAssistantsResponse | None: """List all the assistants.""" - try: - crud_assistant = CRUDAssistant(model=Assistant) - crud_response = await crud_assistant.list(client=session) - response = { - "object": "list", - "data": crud_response, - } - return response - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="No assistants found") from exc + crud_assistant = CRUDAssistant(model=Assistant) + crud_response = await crud_assistant.list(client=session) + + if not crud_response: + return None + + return { + "object": "list", + "data": crud_response, + } @router.get("/{assistant_id}") diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index ea49ff26f..02241c3f2 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -63,16 +63,16 @@ async def upload_file( @router.get("") async def list_files(session: Session) -> ListFilesResponse | None: """List all files.""" - try: - crud_file = CRUDFileObject(model=FileObject) - crud_response = await crud_file.list(client=session) - response = { - "object": "list", - "data": crud_response, - } - return response - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="No file objects found") from exc + crud_file = CRUDFileObject(model=FileObject) + crud_response = await crud_file.list(client=session) + + if crud_response is None: + return None + + return { + "object": "list", + "data": crud_response, + } @router.get("/{file_id}") From e48d779ac5ad121ec0074d33456a69672d862387 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 6 May 2024 11:41:44 -0400 Subject: [PATCH 44/73] amp up test messages --- tests/integration/api/test_assistants.py | 65 ++++++++++++++---------- tests/integration/api/test_files.py | 15 +++--- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index 35bd61c4b..ea8a9460d 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -1,5 +1,6 @@ """Test the API endpoints for assistants.""" +from fastapi import status from fastapi.testclient import TestClient from openai.types.beta import Assistant, AssistantDeleted @@ -28,23 +29,31 @@ def test_assistants(): ) create_response = client.post("/openai/v1/assistants", json=request.model_dump()) - assert create_response.status_code == 200 - assert Assistant.model_validate(create_response.json()) + assert create_response.status_code is status.HTTP_200_OK + assert Assistant.model_validate( + create_response.json() + ), "Create should create an Assistant." + + assistant_id = create_response.json()["id"] list_response = client.get("/openai/v1/assistants") - assert list_response.status_code == 200 + assert list_response.status_code is status.HTTP_200_OK for assistant_object in list_response.json()["data"]: assert Assistant.model_validate( assistant_object - ), "Should return a list of Assistants." + ), "List should return a list of Assistants." - get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") - assert get_response.status_code == 200 - assert Assistant.model_validate(get_response.json()), "Should return an Assistant." + get_response = client.get(f"/openai/v1/assistants/{assistant_id}") + assert get_response.status_code is status.HTTP_200_OK + assert Assistant.model_validate( + get_response.json() + ), f"Get endpoint should return Assistant {assistant_id}." + + modified_name = "test1" request = ModifyAssistantRequest( model="test1", - name="test1", + name=modified_name, description="test1", instructions="test1", tools=[{"type": "file_search"}], @@ -56,47 +65,47 @@ def test_assistants(): ) modify_response = client.post( - f"/openai/v1/assistants/{create_response.json()['id']}", + f"/openai/v1/assistants/{assistant_id}", json=request.model_dump(), ) - assert modify_response.status_code == 200 + assert modify_response.status_code is status.HTTP_200_OK assert Assistant.model_validate( modify_response.json() ), "Should return a Assistant." - assert modify_response.json()["model"] == "test1", "Should be modified." + assert ( + modify_response.json()["name"] == modified_name + ), f"Assistant {assistant_id} should be modified via modify endpoint." - get_modified_response = client.get( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) - assert get_modified_response.status_code == 200 + get_modified_response = client.get(f"/openai/v1/assistants/{assistant_id}") + assert get_modified_response.status_code is status.HTTP_200_OK assert Assistant.model_validate( get_modified_response.json() ), "Should return a Assistant." - assert get_modified_response.json()["model"] == "test1", "Should be modified." + assert ( + get_modified_response.json()["model"] == "test1" + ), f"Get endpoint should return modified Assistant {assistant_id}." - delete_response = client.delete( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) - assert delete_response.status_code == 200 + delete_response = client.delete(f"/openai/v1/assistants/{assistant_id}") + assert delete_response.status_code is status.HTTP_200_OK assert AssistantDeleted.model_validate( delete_response.json() ), "Should return a AssistantDeleted object." - assert delete_response.json()["deleted"] is True, "Should be able to delete." + assert ( + delete_response.json()["deleted"] is True + ), f"Assistant {assistant_id} should be deleted." - delete_response = client.delete( - f"/openai/v1/assistants/{create_response.json()['id']}" - ) + delete_response = client.delete(f"/openai/v1/assistants/{assistant_id}") assert ( - delete_response.status_code == 200 + delete_response.status_code is status.HTTP_200_OK ), "Should return 200 even if the assistant is not found." assert AssistantDeleted.model_validate( delete_response.json() ), "Should return a AssistantDeleted object." assert ( delete_response.json()["deleted"] is False - ), "Should not be able to delete twice." + ), f"Assistant {assistant_id} should not be able to delete twice." # Make sure the assistant is not still present - get_response = client.get(f"/openai/v1/assistants/{create_response.json()['id']}") - assert get_response.status_code == 200 + get_response = client.get(f"/openai/v1/assistants/{assistant_id}") + assert get_response.status_code is status.HTTP_200_OK assert get_response.json() is None diff --git a/tests/integration/api/test_files.py b/tests/integration/api/test_files.py index 46f7c650b..5ff68978b 100644 --- a/tests/integration/api/test_files.py +++ b/tests/integration/api/test_files.py @@ -1,5 +1,6 @@ """Test the API endpoints for files.""" +from fastapi import status from fastapi.testclient import TestClient from openai.types import FileObject, FileDeleted from leapfrogai_api.routers.openai.files import router @@ -18,30 +19,30 @@ def test_files(): data={"purpose": "assistants"}, ) - assert create_response.status_code == 200 + assert create_response.status_code is status.HTTP_200_OK assert FileObject.model_validate(create_response.json()) list_response = client.get("/openai/v1/files") - assert list_response.status_code == 200 + assert list_response.status_code is status.HTTP_200_OK for file_object in list_response.json()["data"]: assert FileObject.model_validate( file_object ), "Should return a list of FileObjects." get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") - assert get_response.status_code == 200 + assert get_response.status_code is status.HTTP_200_OK assert FileObject.model_validate(get_response.json()), "Should return a FileObject." get_content_response = client.get( f"/openai/v1/files/{create_response.json()['id']}/content" ) - assert get_content_response.status_code == 200 + assert get_content_response.status_code is status.HTTP_200_OK assert ( testfile_content.decode() in get_content_response.text ), "Should return the file content." delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") - assert delete_response.status_code == 200 + assert delete_response.status_code is status.HTTP_200_OK assert FileDeleted.model_validate( delete_response.json() ), "Should return a FileDeleted object." @@ -49,7 +50,7 @@ def test_files(): delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") assert ( - delete_response.status_code == 200 + delete_response.status_code is status.HTTP_200_OK ), "Should return 200 even if the file is not found." assert FileDeleted.model_validate( delete_response.json() @@ -60,5 +61,5 @@ def test_files(): # Make sure the file is not still present get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") - assert get_response.status_code == 200 + assert get_response.status_code is status.HTTP_200_OK assert get_response.json() is None From 27d6b7150bc9fced339f4c5e9feabaeee3a4a6c8 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 6 May 2024 11:45:56 -0400 Subject: [PATCH 45/73] amp up test messages --- tests/integration/api/test_assistants.py | 4 ++- tests/integration/api/test_files.py | 36 +++++++++++++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index ea8a9460d..a32516d63 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -108,4 +108,6 @@ def test_assistants(): # Make sure the assistant is not still present get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK - assert get_response.json() is None + assert ( + get_response.json() is None + ), f"Get should not return deleted Assistant {assistant_id}." diff --git a/tests/integration/api/test_files.py b/tests/integration/api/test_files.py index 5ff68978b..ad752ab9c 100644 --- a/tests/integration/api/test_files.py +++ b/tests/integration/api/test_files.py @@ -20,35 +20,41 @@ def test_files(): ) assert create_response.status_code is status.HTTP_200_OK - assert FileObject.model_validate(create_response.json()) + assert FileObject.model_validate( + create_response.json() + ), "Create should create a FileObject." + + file_id = create_response.json()["id"] list_response = client.get("/openai/v1/files") assert list_response.status_code is status.HTTP_200_OK for file_object in list_response.json()["data"]: assert FileObject.model_validate( file_object - ), "Should return a list of FileObjects." + ), "List should return a list of FileObjects." - get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") + get_response = client.get(f"/openai/v1/files/{file_id}") assert get_response.status_code is status.HTTP_200_OK - assert FileObject.model_validate(get_response.json()), "Should return a FileObject." + assert FileObject.model_validate( + get_response.json() + ), f"Get should return FileObject {file_id}." - get_content_response = client.get( - f"/openai/v1/files/{create_response.json()['id']}/content" - ) + get_content_response = client.get(f"/openai/v1/files/{file_id}/content") assert get_content_response.status_code is status.HTTP_200_OK assert ( testfile_content.decode() in get_content_response.text - ), "Should return the file content." + ), f"get_content should return the content for File {file_id}." - delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") + delete_response = client.delete(f"/openai/v1/files/{file_id}") assert delete_response.status_code is status.HTTP_200_OK assert FileDeleted.model_validate( delete_response.json() ), "Should return a FileDeleted object." - assert delete_response.json()["deleted"] is True, "Should be able to delete." + assert ( + delete_response.json()["deleted"] is True + ), f"Delete should be able to delete File {file_id}." - delete_response = client.delete(f"/openai/v1/files/{create_response.json()['id']}") + delete_response = client.delete(f"/openai/v1/files/{file_id}") assert ( delete_response.status_code is status.HTTP_200_OK ), "Should return 200 even if the file is not found." @@ -57,9 +63,11 @@ def test_files(): ), "Should return a FileDeleted object." assert ( delete_response.json()["deleted"] is False - ), "Should not be able to delete twice." + ), f"Delete should not be able to delete File {file_id} twice." # Make sure the file is not still present - get_response = client.get(f"/openai/v1/files/{create_response.json()['id']}") + get_response = client.get(f"/openai/v1/files/{file_id}") assert get_response.status_code is status.HTTP_200_OK - assert get_response.json() is None + assert ( + get_response.json() is None + ), f"Get should not return deleted FileObject {file_id}." From f9cbe526875a15ce6751a490bbae46a3336dc647 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 9 May 2024 09:44:32 -0400 Subject: [PATCH 46/73] update tool validation --- .gitignore | 1 + src/leapfrogai_api/utils/openai_util.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 5bd22dc1c..5e0f60a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ build/ .ruff_cache .branches .temp +src/leapfrogai_api/config.yaml # local model and tokenizer files *.bin diff --git a/src/leapfrogai_api/utils/openai_util.py b/src/leapfrogai_api/utils/openai_util.py index 68fd3c05a..0b1354f84 100644 --- a/src/leapfrogai_api/utils/openai_util.py +++ b/src/leapfrogai_api/utils/openai_util.py @@ -1,6 +1,5 @@ """This module contains utility functions for interacting with OpenAI API.""" -from typing import Dict, List from openai.types.beta import ( CodeInterpreterTool, FileSearchTool, @@ -15,8 +14,16 @@ } -def validate_tools_typed_dict(data: List[Dict]) -> List[AssistantTool]: +def validate_tools_typed_dict(data: list[dict]) -> list[AssistantTool]: """Validate a tool typed dict.""" + + max_supported_tools = 128 # OpenAI sets this to 128 + + if len(data) > max_supported_tools: + raise ValueError("Too many tools specified.") + if len(data) == 0: + raise ValueError("No tools specified.") + for tool_data in data: if "type" not in tool_data: raise ValueError("Tool type not specified.") @@ -28,12 +35,6 @@ def validate_tools_typed_dict(data: List[Dict]) -> List[AssistantTool]: tool_class = tool_mapping[tool_type] tool_instance = tool_class(**tool_data) - if tool_instance is None: - raise ValueError("No tools specified.") - - if len(data) > 128: - raise ValueError("Too many tools specified.") - if isinstance(tool_instance, list): return tool_instance From 309e8c8d2df0f1e0fbe86701c72d5c1933ed2281 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 9 May 2024 09:54:46 -0400 Subject: [PATCH 47/73] update return type --- src/leapfrogai_api/backend/types.py | 2 +- src/leapfrogai_api/routers/openai/files.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/leapfrogai_api/backend/types.py b/src/leapfrogai_api/backend/types.py index 1cfedf1e3..39675eb52 100644 --- a/src/leapfrogai_api/backend/types.py +++ b/src/leapfrogai_api/backend/types.py @@ -234,7 +234,7 @@ def as_form( class ListFilesResponse(BaseModel): """Response object for listing files.""" - object: str = "list" + object: str = Literal["list"] data: list[FileObject] = [] diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 02241c3f2..e3e044425 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -69,10 +69,10 @@ async def list_files(session: Session) -> ListFilesResponse | None: if crud_response is None: return None - return { - "object": "list", - "data": crud_response, - } + return ListFilesResponse( + object="list", + data=crud_response, + ) @router.get("/{file_id}") From 665b9d3bf73dba253fe2a293bfb24cd7e7bdcc6f Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 9 May 2024 09:56:11 -0400 Subject: [PATCH 48/73] update return type --- src/leapfrogai_api/backend/types.py | 2 +- src/leapfrogai_api/routers/openai/assistants.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/leapfrogai_api/backend/types.py b/src/leapfrogai_api/backend/types.py index 39675eb52..df3c6c3dc 100644 --- a/src/leapfrogai_api/backend/types.py +++ b/src/leapfrogai_api/backend/types.py @@ -267,5 +267,5 @@ class ModifyAssistantRequest(CreateAssistantRequest): class ListAssistantsResponse(BaseModel): """Response object for listing files.""" - object: str = "list" + object: str = Literal["list"] data: list[Assistant] = [] diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 1f9131949..e1e5547de 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -61,11 +61,10 @@ async def list_assistants(session: Session) -> ListAssistantsResponse | None: if not crud_response: return None - return { - "object": "list", - "data": crud_response, - } - + return ListAssistantsResponse( + object="list", + data=crud_response, + ) @router.get("/{assistant_id}") async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | None: From 8975705942f05b99ece0ff03c8546ea9df830961 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 9 May 2024 09:56:53 -0400 Subject: [PATCH 49/73] update return type --- src/leapfrogai_api/routers/openai/assistants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index e1e5547de..b25c4f654 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -66,6 +66,7 @@ async def list_assistants(session: Session) -> ListAssistantsResponse | None: data=crud_response, ) + @router.get("/{assistant_id}") async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | None: """Retrieve an assistant.""" From 9c4d013fb8e4917d9131a910ac764f80749627f9 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 9 May 2024 11:49:58 -0400 Subject: [PATCH 50/73] fix validate tools typed dict --- src/leapfrogai_api/utils/openai_util.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/leapfrogai_api/utils/openai_util.py b/src/leapfrogai_api/utils/openai_util.py index 0b1354f84..f8e1ec88a 100644 --- a/src/leapfrogai_api/utils/openai_util.py +++ b/src/leapfrogai_api/utils/openai_util.py @@ -21,8 +21,8 @@ def validate_tools_typed_dict(data: list[dict]) -> list[AssistantTool]: if len(data) > max_supported_tools: raise ValueError("Too many tools specified.") - if len(data) == 0: - raise ValueError("No tools specified.") + + tool_instance = [] for tool_data in data: if "type" not in tool_data: @@ -33,9 +33,6 @@ def validate_tools_typed_dict(data: list[dict]) -> list[AssistantTool]: raise ValueError(f"Unknown tool type: {tool_type}") tool_class = tool_mapping[tool_type] - tool_instance = tool_class(**tool_data) - - if isinstance(tool_instance, list): - return tool_instance + tool_instance.append(tool_class(**tool_data)) - return [tool_instance] + return tool_instance From 74b4425f6603a636789f6239b8bcb33073e665a2 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 12:31:27 -0400 Subject: [PATCH 51/73] address return ListFilesResponse instead of none --- src/leapfrogai_api/routers/openai/files.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index e3e044425..c0495d37c 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -61,13 +61,16 @@ async def upload_file( @router.get("") -async def list_files(session: Session) -> ListFilesResponse | None: +async def list_files(session: Session) -> ListFilesResponse: """List all files.""" crud_file = CRUDFileObject(model=FileObject) crud_response = await crud_file.list(client=session) if crud_response is None: - return None + return ListFilesResponse( + object="list", + data=[], + ) return ListFilesResponse( object="list", From 50fca736bca2ffa30e34dc3c35fdfed850ac97e8 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 12:35:53 -0400 Subject: [PATCH 52/73] add a reference in README/Integration Tests to README/Local Development --- src/leapfrogai_api/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leapfrogai_api/README.md b/src/leapfrogai_api/README.md index 52a4efc40..b7ddfe931 100644 --- a/src/leapfrogai_api/README.md +++ b/src/leapfrogai_api/README.md @@ -34,7 +34,7 @@ The integration tests serve to identify any mismatches between components: - DB CRUD operations - Schema mismatches -Integration tests require a Supbase instance and environment variables configured. +Integration tests require a Supbase instance and environment variables configured (see [Local Development](#local-development)). From this directory run: From 15a8aef8cb136521ccde042fb3210f3426fc678c Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 12:37:54 -0400 Subject: [PATCH 53/73] fixed typo --- src/leapfrogai_api/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leapfrogai_api/README.md b/src/leapfrogai_api/README.md index b7ddfe931..0ece923f8 100644 --- a/src/leapfrogai_api/README.md +++ b/src/leapfrogai_api/README.md @@ -34,7 +34,7 @@ The integration tests serve to identify any mismatches between components: - DB CRUD operations - Schema mismatches -Integration tests require a Supbase instance and environment variables configured (see [Local Development](#local-development)). +Integration tests require a Supabase instance and environment variables configured (see [Local Development](#local-development)). From this directory run: From 11ab054610fde23c2069e0eef429843165d9e709 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 13:53:32 -0400 Subject: [PATCH 54/73] add comment to CRUD operations about why we delete the id field --- src/leapfrogai_api/data/crud_assistant_object.py | 4 +++- src/leapfrogai_api/data/crud_file_object.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/leapfrogai_api/data/crud_assistant_object.py b/src/leapfrogai_api/data/crud_assistant_object.py index 8c37932c7..6e5d2c952 100644 --- a/src/leapfrogai_api/data/crud_assistant_object.py +++ b/src/leapfrogai_api/data/crud_assistant_object.py @@ -15,7 +15,9 @@ async def create( ) -> Assistant | None: """Create a new assistant.""" assistant_object_dict = assistant.model_dump() - if assistant_object_dict.get("id") == "": + if ( + assistant_object_dict.get("id") == "" + ): # Ensure the ID is generated by Supabase del assistant_object_dict["id"] data, _count = ( await client.table("assistant_objects") diff --git a/src/leapfrogai_api/data/crud_file_object.py b/src/leapfrogai_api/data/crud_file_object.py index b19675c89..d37749998 100644 --- a/src/leapfrogai_api/data/crud_file_object.py +++ b/src/leapfrogai_api/data/crud_file_object.py @@ -15,7 +15,7 @@ async def create( ) -> FileObject | None: """Create a new file object.""" file_object_dict = file_object.model_dump() - if file_object_dict.get("id") == "": + if file_object_dict.get("id") == "": # Ensure the ID is generated by Supabase del file_object_dict["id"] data, _count = ( await client.table("file_objects").insert(file_object_dict).execute() From ce01502eb7fd9c703e8986fdb2af24b5a98a1f65 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 13:56:31 -0400 Subject: [PATCH 55/73] ignore config.yaml s --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5e0f60a7d..090ed4703 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ build/ .ruff_cache .branches .temp -src/leapfrogai_api/config.yaml +config.yaml # local model and tokenizer files *.bin From e9b88185b5404f8ed111864020f0233ce94cb7fc Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 17:56:11 -0400 Subject: [PATCH 56/73] beefing up api endpoint tests --- tests/pytest/leapfrogai_api/test_api.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/pytest/leapfrogai_api/test_api.py b/tests/pytest/leapfrogai_api/test_api.py index 29ca44db6..550e63794 100644 --- a/tests/pytest/leapfrogai_api/test_api.py +++ b/tests/pytest/leapfrogai_api/test_api.py @@ -77,12 +77,33 @@ def test_routes(): "/openai/v1/assistants": ["POST"], } + assistants_routes = [ + ("/openai/v1/assistants", "create_assistant", ["POST"]), + ("/openai/v1/assistants", "list_assistants", ["GET"]), + ("/openai/v1/assistants/{assistant_id}", "retrieve_assistant", ["GET"]), + ("/openai/v1/assistants/{assistant_id}", "modify_assistant", ["POST"]), + ("/openai/v1/assistants/{assistant_id}", "delete_assistant", ["DELETE"]), + ] + actual_routes = app.routes for route in actual_routes: if hasattr(route, "path") and route.path in expected_routes: assert route.methods == set(expected_routes[route.path]) del expected_routes[route.path] + for route, name, methods in assistants_routes: + found = False + for actual_route in actual_routes: + if ( + hasattr(actual_route, "path") + and actual_route.path == route + and actual_route.name == name + ): + assert actual_route.methods == set(methods) + found = True + break + assert found, f"Missing route: {route}, {name}, {methods}" + assert len(expected_routes) == 0 From a1ec9d425bfd6635b069d0ba98aa44730056d2d4 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 18:41:37 -0400 Subject: [PATCH 57/73] break up assistants tests --- tests/integration/api/test_assistants.py | 67 ++++++++++++++++++++---- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index a32516d63..2e365a353 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -1,6 +1,7 @@ """Test the API endpoints for assistants.""" -from fastapi import status +import pytest +from fastapi import Response, status from fastapi.testclient import TestClient from openai.types.beta import Assistant, AssistantDeleted @@ -10,11 +11,15 @@ ModifyAssistantRequest, ) -client = TestClient(router) +assistant_response: Response -def test_assistants(): +@pytest.fixture(scope="session", autouse=True) +def create_assistant(client: TestClient = TestClient(router)): """Test creating an assistant. Requires a running Supabase instance.""" + + global assistant_response # pylint: disable=global-statement + request = CreateAssistantRequest( model="test", name="test", @@ -28,14 +33,32 @@ def test_assistants(): response_format="auto", ) - create_response = client.post("/openai/v1/assistants", json=request.model_dump()) - assert create_response.status_code is status.HTTP_200_OK + assistant_response = client.post("/openai/v1/assistants", json=request.model_dump()) + + +def test_create(): + """Test creating an assistant. Requires a running Supabase instance.""" + assert assistant_response.status_code is status.HTTP_200_OK assert Assistant.model_validate( - create_response.json() + assistant_response.json() ), "Create should create an Assistant." - assistant_id = create_response.json()["id"] +def test_get(): + """Test getting an assistant. Requires a running Supabase instance.""" + assistant_id = assistant_response.json()["id"] + + client = TestClient(router) + get_response = client.get(f"/openai/v1/assistants/{assistant_id}") + assert get_response.status_code is status.HTTP_200_OK + assert Assistant.model_validate( + get_response.json() + ), f"Get should return Assistant {assistant_id}." + + +def test_list(): + """Test listing assistants. Requires a running Supabase instance.""" + client = TestClient(router) list_response = client.get("/openai/v1/assistants") assert list_response.status_code is status.HTTP_200_OK for assistant_object in list_response.json()["data"]: @@ -43,6 +66,13 @@ def test_assistants(): assistant_object ), "List should return a list of Assistants." + +def test_modify(): + """Test modifying an assistant. Requires a running Supabase instance.""" + assistant_id = assistant_response.json()["id"] + + client = TestClient(router) + get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK assert Assistant.model_validate( @@ -85,6 +115,12 @@ def test_assistants(): get_modified_response.json()["model"] == "test1" ), f"Get endpoint should return modified Assistant {assistant_id}." + +def test_delete(): + """Test deleting an assistant. Requires a running Supabase instance.""" + assistant_id = assistant_response.json()["id"] + + client = TestClient(router) delete_response = client.delete(f"/openai/v1/assistants/{assistant_id}") assert delete_response.status_code is status.HTTP_200_OK assert AssistantDeleted.model_validate( @@ -94,10 +130,14 @@ def test_assistants(): delete_response.json()["deleted"] is True ), f"Assistant {assistant_id} should be deleted." + +def test_delete_twice(): + """Test deleting an assistant twice. Requires a running Supabase instance.""" + assistant_id = assistant_response.json()["id"] + + client = TestClient(router) delete_response = client.delete(f"/openai/v1/assistants/{assistant_id}") - assert ( - delete_response.status_code is status.HTTP_200_OK - ), "Should return 200 even if the assistant is not found." + assert delete_response.status_code is status.HTTP_200_OK assert AssistantDeleted.model_validate( delete_response.json() ), "Should return a AssistantDeleted object." @@ -105,7 +145,12 @@ def test_assistants(): delete_response.json()["deleted"] is False ), f"Assistant {assistant_id} should not be able to delete twice." - # Make sure the assistant is not still present + +def test_delete_nonexistent(): + """Test deleting a nonexistent assistant. Requires a running Supabase instance.""" + assistant_id = assistant_response.json()["id"] + + client = TestClient(router) get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK assert ( From 38ddc01f21e7a99ff2e579b1ec9df8e609051ffa Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Wed, 15 May 2024 23:33:56 -0400 Subject: [PATCH 58/73] update files tests --- tests/integration/api/test_assistants.py | 20 ++---- tests/integration/api/test_files.py | 83 +++++++++++++++++------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index 2e365a353..e0604c6ec 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -13,10 +13,12 @@ assistant_response: Response +client = TestClient(router) + @pytest.fixture(scope="session", autouse=True) -def create_assistant(client: TestClient = TestClient(router)): - """Test creating an assistant. Requires a running Supabase instance.""" +def create_assistant(): + """Create an assistant for testing. Requires a running Supabase instance.""" global assistant_response # pylint: disable=global-statement @@ -47,8 +49,6 @@ def test_create(): def test_get(): """Test getting an assistant. Requires a running Supabase instance.""" assistant_id = assistant_response.json()["id"] - - client = TestClient(router) get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK assert Assistant.model_validate( @@ -58,7 +58,6 @@ def test_get(): def test_list(): """Test listing assistants. Requires a running Supabase instance.""" - client = TestClient(router) list_response = client.get("/openai/v1/assistants") assert list_response.status_code is status.HTTP_200_OK for assistant_object in list_response.json()["data"]: @@ -70,9 +69,6 @@ def test_list(): def test_modify(): """Test modifying an assistant. Requires a running Supabase instance.""" assistant_id = assistant_response.json()["id"] - - client = TestClient(router) - get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK assert Assistant.model_validate( @@ -120,7 +116,6 @@ def test_delete(): """Test deleting an assistant. Requires a running Supabase instance.""" assistant_id = assistant_response.json()["id"] - client = TestClient(router) delete_response = client.delete(f"/openai/v1/assistants/{assistant_id}") assert delete_response.status_code is status.HTTP_200_OK assert AssistantDeleted.model_validate( @@ -134,8 +129,6 @@ def test_delete(): def test_delete_twice(): """Test deleting an assistant twice. Requires a running Supabase instance.""" assistant_id = assistant_response.json()["id"] - - client = TestClient(router) delete_response = client.delete(f"/openai/v1/assistants/{assistant_id}") assert delete_response.status_code is status.HTTP_200_OK assert AssistantDeleted.model_validate( @@ -146,11 +139,10 @@ def test_delete_twice(): ), f"Assistant {assistant_id} should not be able to delete twice." -def test_delete_nonexistent(): - """Test deleting a nonexistent assistant. Requires a running Supabase instance.""" +def test_get_nonexistent(): + """Test getting a nonexistent assistant. Requires a running Supabase instance.""" assistant_id = assistant_response.json()["id"] - client = TestClient(router) get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK assert ( diff --git a/tests/integration/api/test_files.py b/tests/integration/api/test_files.py index ad752ab9c..3ed61caed 100644 --- a/tests/integration/api/test_files.py +++ b/tests/integration/api/test_files.py @@ -1,50 +1,81 @@ """Test the API endpoints for files.""" -from fastapi import status +import pytest +from fastapi import Response, status from fastapi.testclient import TestClient from openai.types import FileObject, FileDeleted + from leapfrogai_api.routers.openai.files import router +file_response: Response +testfile_content: bytes + client = TestClient(router) -def test_files(): - """Test creating a file. Requires a running Supabase instance.""" - +@pytest.fixture(scope="session", autouse=True) +def read_testfile(): + """Read the test file content.""" + global testfile_content # pylint: disable=global-statement with open("tests/data/test.txt", "rb") as testfile: testfile_content = testfile.read() - create_response = client.post( - "/openai/v1/files", - files={"file": ("test.txt", testfile, "text/plain")}, - data={"purpose": "assistants"}, - ) - assert create_response.status_code is status.HTTP_200_OK + +@pytest.fixture(scope="session", autouse=True) +def create_file(read_testfile): # pylint: disable=redefined-outer-name, unused-argument + """Create a file for testing. Requires a running Supabase instance.""" + + global file_response # pylint: disable=global-statement + + file_response = client.post( + "/openai/v1/files", + files={"file": ("test.txt", testfile_content, "text/plain")}, + data={"purpose": "assistants"}, + ) + + +def test_create(): + """Test creating a file. Requires a running Supabase instance.""" + assert file_response.status_code is status.HTTP_200_OK assert FileObject.model_validate( - create_response.json() + file_response.json() ), "Create should create a FileObject." - file_id = create_response.json()["id"] - - list_response = client.get("/openai/v1/files") - assert list_response.status_code is status.HTTP_200_OK - for file_object in list_response.json()["data"]: - assert FileObject.model_validate( - file_object - ), "List should return a list of FileObjects." +def test_get(): + """Test getting a file. Requires a running Supabase instance.""" + file_id = file_response.json()["id"] get_response = client.get(f"/openai/v1/files/{file_id}") assert get_response.status_code is status.HTTP_200_OK assert FileObject.model_validate( get_response.json() ), f"Get should return FileObject {file_id}." + +def test_get_content(): + """Test getting file content. Requires a running Supabase instance.""" + file_id = file_response.json()["id"] get_content_response = client.get(f"/openai/v1/files/{file_id}/content") assert get_content_response.status_code is status.HTTP_200_OK assert ( testfile_content.decode() in get_content_response.text ), f"get_content should return the content for File {file_id}." + +def test_list(): + """Test listing files. Requires a running Supabase instance.""" + list_response = client.get("/openai/v1/files") + assert list_response.status_code is status.HTTP_200_OK + for file_object in list_response.json()["data"]: + assert FileObject.model_validate( + file_object + ), "List should return a list of FileObjects." + + +def test_delete(): + """Test deleting a file. Requires a running Supabase instance.""" + file_id = file_response.json()["id"] + delete_response = client.delete(f"/openai/v1/files/{file_id}") assert delete_response.status_code is status.HTTP_200_OK assert FileDeleted.model_validate( @@ -54,10 +85,12 @@ def test_files(): delete_response.json()["deleted"] is True ), f"Delete should be able to delete File {file_id}." + +def test_delete_twice(): + """Test deleting a file twice. Requires a running Supabase instance.""" + file_id = file_response.json()["id"] delete_response = client.delete(f"/openai/v1/files/{file_id}") - assert ( - delete_response.status_code is status.HTTP_200_OK - ), "Should return 200 even if the file is not found." + assert delete_response.status_code is status.HTTP_200_OK assert FileDeleted.model_validate( delete_response.json() ), "Should return a FileDeleted object." @@ -65,7 +98,11 @@ def test_files(): delete_response.json()["deleted"] is False ), f"Delete should not be able to delete File {file_id} twice." - # Make sure the file is not still present + +def test_get_nonexistent(): + """Test getting a nonexistent file. Requires a running Supabase instance.""" + file_id = file_response.json()["id"] + get_response = client.get(f"/openai/v1/files/{file_id}") assert get_response.status_code is status.HTTP_200_OK assert ( From 2a903a63c681136a78ca22e3a61b583cbccba883 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 16 May 2024 11:26:52 -0400 Subject: [PATCH 59/73] updating status codes --- .../routers/openai/assistants.py | 41 +++++++-------- src/leapfrogai_api/routers/openai/files.py | 52 +++++++------------ 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index b25c4f654..1e26bbcec 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -1,7 +1,7 @@ """OpenAI Compliant Assistants API Router.""" import time -from fastapi import HTTPException, APIRouter +from fastapi import HTTPException, APIRouter, status from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources from leapfrogai_api.backend.types import ( @@ -40,7 +40,8 @@ async def create_assistant( ) except Exception as exc: raise HTTPException( - status_code=405, detail="Unable to parse assistant request" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unable to parse assistant request", ) from exc try: @@ -48,33 +49,29 @@ async def create_assistant( return await crud_assistant.create(assistant=assistant, client=session) except Exception as exc: raise HTTPException( - status_code=405, detail="Unable to create assistant" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unable to create assistant", ) from exc @router.get("") -async def list_assistants(session: Session) -> ListAssistantsResponse | None: +async def list_assistants(session: Session) -> ListAssistantsResponse: """List all the assistants.""" crud_assistant = CRUDAssistant(model=Assistant) crud_response = await crud_assistant.list(client=session) - if not crud_response: - return None - return ListAssistantsResponse( object="list", - data=crud_response, + data=crud_response or [], ) @router.get("/{assistant_id}") async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | None: """Retrieve an assistant.""" - try: - crud_assistant = CRUDAssistant(model=Assistant) - return await crud_assistant.get(assistant_id=assistant_id, client=session) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc + + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.get(assistant_id=assistant_id, client=session) @router.post("/{assistant_id}") @@ -85,7 +82,9 @@ async def modify_assistant( old_assistant = await retrieve_assistant(session, assistant_id) if old_assistant is None: - raise HTTPException(status_code=404, detail="Assistant not found") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Assistant not found" + ) try: assistant = Assistant( @@ -106,7 +105,8 @@ async def modify_assistant( ) except Exception as exc: raise HTTPException( - status_code=405, detail="Unable to parse assistant request" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unable to parse assistant request", ) from exc try: @@ -115,14 +115,13 @@ async def modify_assistant( assistant_id=assistant_id, assistant=assistant, client=session ) except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Assistant not found" + ) from exc @router.delete("/{assistant_id}") async def delete_assistant(session: Session, assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" - try: - crud_assistant = CRUDAssistant(model=Assistant) - return await crud_assistant.delete(assistant_id=assistant_id, client=session) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="Assistant not found") from exc + crud_assistant = CRUDAssistant(model=Assistant) + return await crud_assistant.delete(assistant_id=assistant_id, client=session) diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index c0495d37c..cf23aa22b 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -1,7 +1,7 @@ """OpenAI Compliant Files API Router.""" import time -from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from openai.types import FileDeleted, FileObject from leapfrogai_api.backend.types import ListFilesResponse, UploadFileRequest from leapfrogai_api.data.crud_file_object import CRUDFileObject @@ -30,7 +30,9 @@ async def upload_file( status_details=None, ) except Exception as exc: - raise HTTPException(status_code=500, detail="Failed to parse file") from exc + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to parse file" + ) from exc try: crud_file_object = CRUDFileObject(model=FileObject) @@ -43,7 +45,8 @@ async def upload_file( except Exception as exc: raise HTTPException( - status_code=500, detail="Failed to store file object" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to store file object", ) from exc try: @@ -52,9 +55,12 @@ async def upload_file( client=client, file=request.file, id_=file_object.id ) except Exception as exc: - crud_file_object.delete(file_id=file_object.id, client=client) + crud_file_object.delete( + file_id=file_object.id, client=client + ) # If we fail to upload the file, delete the file object raise HTTPException( - status_code=500, detail="Failed to store file in bucket" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to store file in bucket", ) from exc return file_object @@ -66,45 +72,29 @@ async def list_files(session: Session) -> ListFilesResponse: crud_file = CRUDFileObject(model=FileObject) crud_response = await crud_file.list(client=session) - if crud_response is None: - return ListFilesResponse( - object="list", - data=[], - ) - return ListFilesResponse( object="list", - data=crud_response, + data=crud_response or [], ) @router.get("/{file_id}") async def retrieve_file(client: Session, file_id: str) -> FileObject | None: """Retrieve a file.""" - try: - crud_file = CRUDFileObject(model=FileObject) - return await crud_file.get(file_id=file_id, client=client) - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc - except ValueError as exc: - raise HTTPException( - status_code=500, detail="Multiple files found with same id" - ) from exc + crud_file = CRUDFileObject(model=FileObject) + return await crud_file.get(file_id=file_id, client=client) @router.delete("/{file_id}") async def delete_file(session: Session, file_id: str) -> FileDeleted: """Delete a file.""" - try: - crud_file_object = CRUDFileObject(model=FileObject) - file_deleted = await crud_file_object.delete(file_id=file_id, client=session) + crud_file_object = CRUDFileObject(model=FileObject) + file_deleted = await crud_file_object.delete(file_id=file_id, client=session) - crud_file_bucket = CRUDFileBucket(model=UploadFile) - await crud_file_bucket.delete(client=session, id_=file_id) + crud_file_bucket = CRUDFileBucket(model=UploadFile) + await crud_file_bucket.delete(client=session, id_=file_id) - return file_deleted - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc + return file_deleted @router.get("/{file_id}/content") @@ -114,8 +104,6 @@ async def retrieve_file_content(session: Session, file_id: str): crud_file_bucket = CRUDFileBucket(model=UploadFile) return await crud_file_bucket.download(client=session, id_=file_id) except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail="File not found") from exc - except ValueError as exc: raise HTTPException( - status_code=500, detail="Multiple files found with same id" + status_code=status.HTTP_400_BAD_REQUEST, detail="File not found" ) from exc From 29d42343d27b2461fcc3e8ab4be62d8ab637efe9 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 16 May 2024 12:13:58 -0400 Subject: [PATCH 60/73] refactoring a bit to remove code duplication --- .../data/crud_assistant_object.py | 85 ++++--------------- src/leapfrogai_api/data/crud_base.py | 75 ++++++++++++++++ src/leapfrogai_api/data/crud_file_object.py | 73 ++++------------ .../routers/openai/assistants.py | 21 +++-- src/leapfrogai_api/routers/openai/files.py | 24 +++--- .../20240419164109_init-assistant.sql | 2 +- .../migrations/20240422015807_init-files.sql | 2 +- 7 files changed, 133 insertions(+), 149 deletions(-) create mode 100644 src/leapfrogai_api/data/crud_base.py diff --git a/src/leapfrogai_api/data/crud_assistant_object.py b/src/leapfrogai_api/data/crud_assistant_object.py index 6e5d2c952..70be1012d 100644 --- a/src/leapfrogai_api/data/crud_assistant_object.py +++ b/src/leapfrogai_api/data/crud_assistant_object.py @@ -1,89 +1,34 @@ """CRUD Operations for Assistant.""" +from openai.types.beta import Assistant from supabase_py_async import AsyncClient -from openai.types.beta import Assistant, AssistantDeleted +from leapfrogai_api.data.crud_base import CRUDBase -class CRUDAssistant: +class CRUDAssistant(CRUDBase[Assistant]): """CRUD Operations for Assistant""" - def __init__(self, model: type[Assistant]): - self.model = model + def __init__(self, model: type[Assistant], table_name: str = "assistant_objects"): + super().__init__(model=model, table_name=table_name) - async def create( - self, client: AsyncClient, assistant: Assistant - ) -> Assistant | None: + async def create(self, db: AsyncClient, object_: Assistant) -> Assistant | None: """Create a new assistant.""" - assistant_object_dict = assistant.model_dump() - if ( - assistant_object_dict.get("id") == "" - ): # Ensure the ID is generated by Supabase - del assistant_object_dict["id"] - data, _count = ( - await client.table("assistant_objects") - .insert(assistant_object_dict) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None + return await super().create(db=db, object_=object_) - async def get(self, client: AsyncClient, assistant_id: str) -> Assistant | None: + async def get(self, id_: str, db: AsyncClient) -> Assistant | None: """Get an assistant by its ID.""" - data, _count = ( - await client.table("assistant_objects") - .select("*") - .eq("id", assistant_id) - .execute() - ) + return await super().get(db=db, id_=id_) - _, response = data - - if response: - return self.model(**response[0]) - return None - - async def list(self, client: AsyncClient) -> list[Assistant] | None: + async def list(self, db: AsyncClient) -> list[Assistant] | None: """List all assistants.""" - data, _count = await client.table("assistant_objects").select("*").execute() - - _, response = data - - if response: - return [self.model(**item) for item in response] - return None + return await super().list(db=db) async def update( - self, client: AsyncClient, assistant_id: str, assistant: Assistant + self, id_: str, db: AsyncClient, object_: Assistant ) -> Assistant | None: """Update an assistant by its ID.""" - data, _count = ( - await client.table("assistant_objects") - .update(assistant.model_dump()) - .eq("id", assistant_id) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None + return await super().update(id_=id_, db=db, object_=object_) - async def delete(self, client: AsyncClient, assistant_id: str) -> AssistantDeleted: + async def delete(self, id_: str, db: AsyncClient) -> bool: """Delete an assistant by its ID.""" - data, _count = ( - await client.table("assistant_objects") - .delete() - .eq("id", assistant_id) - .execute() - ) - - _, response = data - - return AssistantDeleted( - id=assistant_id, deleted=bool(response), object="assistant.deleted" - ) + return await super().delete(id_=id_, db=db) diff --git a/src/leapfrogai_api/data/crud_base.py b/src/leapfrogai_api/data/crud_base.py new file mode 100644 index 000000000..6e56b00c1 --- /dev/null +++ b/src/leapfrogai_api/data/crud_base.py @@ -0,0 +1,75 @@ +"""CRUD Operations for VectorStore.""" + +from typing import Generic, TypeVar +from supabase_py_async import AsyncClient +from pydantic import BaseModel + +ModelType = TypeVar("ModelType", bound=BaseModel) + + +class CRUDBase(Generic[ModelType]): + """CRUD Operations""" + + def __init__(self, model: type[ModelType], table_name: str): + self.model = model + self.table_name = table_name + + async def create(self, db: AsyncClient, object_: ModelType) -> ModelType | None: + """Create new row.""" + dict_ = object_.model_dump() + del dict_["id"] # Ensure this is created by the database + del dict_["created_at"] # Ensure this is created by the database + data, _count = await db.table(self.table_name).insert(dict_).execute() + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def get(self, id_: str, db: AsyncClient) -> ModelType | None: + """Get row by ID.""" + data, _count = ( + await db.table(self.table_name).select("*").eq("id", id_).execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def list(self, db: AsyncClient) -> list[ModelType] | None: + """List all rows.""" + data, _count = await db.table(self.table_name).select("*").execute() + + _, response = data + + if response: + return [self.model(**item) for item in response] + return None + + async def update( + self, id_: str, db: AsyncClient, object_: ModelType + ) -> ModelType | None: + """Update a vector store by its ID.""" + data, _count = ( + await db.table(self.table_name) + .update(object_.model_dump()) + .eq("id", id_) + .execute() + ) + + _, response = data + + if response: + return self.model(**response[0]) + return None + + async def delete(self, id_: str, db: AsyncClient) -> bool: + """Delete a vector store by its ID.""" + data, _count = await db.table(self.table_name).delete().eq("id", id_).execute() + + _, response = data + + return bool(response) diff --git a/src/leapfrogai_api/data/crud_file_object.py b/src/leapfrogai_api/data/crud_file_object.py index d37749998..c98a2e585 100644 --- a/src/leapfrogai_api/data/crud_file_object.py +++ b/src/leapfrogai_api/data/crud_file_object.py @@ -1,77 +1,34 @@ """CRUD Operations for FileObject""" +from openai.types import FileObject from supabase_py_async import AsyncClient -from openai.types import FileObject, FileDeleted +from leapfrogai_api.data.crud_base import CRUDBase -class CRUDFileObject: +class CRUDFileObject(CRUDBase[FileObject]): """CRUD Operations for FileObject""" - def __init__(self, model: type[FileObject]): - self.model = model + def __init__(self, model: type[FileObject], table_name: str = "file_objects"): + super().__init__(model=model, table_name=table_name) - async def create( - self, client: AsyncClient, file_object: FileObject - ) -> FileObject | None: + async def create(self, db: AsyncClient, object_: FileObject) -> FileObject | None: """Create a new file object.""" - file_object_dict = file_object.model_dump() - if file_object_dict.get("id") == "": # Ensure the ID is generated by Supabase - del file_object_dict["id"] - data, _count = ( - await client.table("file_objects").insert(file_object_dict).execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None + return await super().create(db=db, object_=object_) - async def get(self, client: AsyncClient, file_id: str) -> FileObject | None: + async def get(self, id_: str, db: AsyncClient) -> FileObject | None: """Get a file object by its ID.""" - data, _count = ( - await client.table("file_objects").select("*").eq("id", file_id).execute() - ) + return await super().get(db=db, id_=id_) - _, response = data - - if response: - return self.model(**response[0]) - return None - - async def list(self, client: AsyncClient) -> list[FileObject] | None: + async def list(self, db: AsyncClient) -> list[FileObject] | None: """List all file objects.""" - data, _count = await client.table("file_objects").select("*").execute() - - _, response = data - - if response: - return [self.model(**item) for item in response] - return None + return await super().list(db=db) async def update( - self, client: AsyncClient, file_id: str, file_object: FileObject + self, id_: str, db: AsyncClient, object_: FileObject ) -> FileObject | None: """Update a file object by its ID.""" - data, _count = ( - await client.table("file_objects") - .update(file_object.model_dump()) - .eq("id", file_id) - .execute() - ) - - _, response = data - - if response: - return self.model(**response[0]) - return None + return await super().update(id_=id_, db=db, object_=object_) - async def delete(self, client: AsyncClient, file_id: str) -> FileDeleted: + async def delete(self, id_: str, db: AsyncClient) -> bool: """Delete a file object by its ID.""" - data, _count = ( - await client.table("file_objects").delete().eq("id", file_id).execute() - ) - - _, response = data - - return FileDeleted(id=file_id, deleted=bool(response), object="file") + return await super().delete(id_=id_, db=db) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 1e26bbcec..670e98d86 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -46,7 +46,7 @@ async def create_assistant( try: crud_assistant = CRUDAssistant(model=Assistant) - return await crud_assistant.create(assistant=assistant, client=session) + return await crud_assistant.create(db=session, object_=assistant) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -58,7 +58,7 @@ async def create_assistant( async def list_assistants(session: Session) -> ListAssistantsResponse: """List all the assistants.""" crud_assistant = CRUDAssistant(model=Assistant) - crud_response = await crud_assistant.list(client=session) + crud_response = await crud_assistant.list(db=session) return ListAssistantsResponse( object="list", @@ -71,7 +71,7 @@ async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | """Retrieve an assistant.""" crud_assistant = CRUDAssistant(model=Assistant) - return await crud_assistant.get(assistant_id=assistant_id, client=session) + return await crud_assistant.get(db=session, id_=assistant_id) @router.post("/{assistant_id}") @@ -79,15 +79,16 @@ async def modify_assistant( session: Session, assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: """Modify an assistant.""" + crud_assistant = CRUDAssistant(model=Assistant) - old_assistant = await retrieve_assistant(session, assistant_id) + old_assistant = await crud_assistant.get(db=session, id_=assistant_id) if old_assistant is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Assistant not found" ) try: - assistant = Assistant( + new_assistant = Assistant( id=assistant_id, created_at=old_assistant.created_at, name=request.name or old_assistant.name, @@ -110,9 +111,8 @@ async def modify_assistant( ) from exc try: - crud_assistant = CRUDAssistant(model=Assistant) return await crud_assistant.update( - assistant_id=assistant_id, assistant=assistant, client=session + db=session, object_=new_assistant, id_=assistant_id ) except FileNotFoundError as exc: raise HTTPException( @@ -124,4 +124,9 @@ async def modify_assistant( async def delete_assistant(session: Session, assistant_id: str) -> AssistantDeleted: """Delete an assistant.""" crud_assistant = CRUDAssistant(model=Assistant) - return await crud_assistant.delete(assistant_id=assistant_id, client=session) + assistant_deleted = await crud_assistant.delete(db=session, id_=assistant_id) + return AssistantDeleted( + id=assistant_id, + deleted=bool(assistant_deleted), + object="assistant.deleted", + ) diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index cf23aa22b..6ada5c445 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -13,7 +13,7 @@ @router.post("") async def upload_file( - client: Session, + session: Session, request: UploadFileRequest = Depends(UploadFileRequest.as_form), ) -> FileObject: """Upload a file.""" @@ -36,9 +36,7 @@ async def upload_file( try: crud_file_object = CRUDFileObject(model=FileObject) - file_object = await crud_file_object.create( - file_object=file_object, client=client - ) + file_object = await crud_file_object.create(db=session, object_=file_object) if not file_object: raise HTTPException(status_code=500, detail="Failed to create file object") @@ -52,11 +50,11 @@ async def upload_file( try: crud_file_bucket = CRUDFileBucket(model=UploadFile) await crud_file_bucket.upload( - client=client, file=request.file, id_=file_object.id + client=session, file=request.file, id_=file_object.id ) except Exception as exc: crud_file_object.delete( - file_id=file_object.id, client=client + db=session, id_=file_object.id ) # If we fail to upload the file, delete the file object raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -70,7 +68,7 @@ async def upload_file( async def list_files(session: Session) -> ListFilesResponse: """List all files.""" crud_file = CRUDFileObject(model=FileObject) - crud_response = await crud_file.list(client=session) + crud_response = await crud_file.list(db=session) return ListFilesResponse( object="list", @@ -79,22 +77,26 @@ async def list_files(session: Session) -> ListFilesResponse: @router.get("/{file_id}") -async def retrieve_file(client: Session, file_id: str) -> FileObject | None: +async def retrieve_file(session: Session, file_id: str) -> FileObject | None: """Retrieve a file.""" crud_file = CRUDFileObject(model=FileObject) - return await crud_file.get(file_id=file_id, client=client) + return await crud_file.get(db=session, id_=file_id) @router.delete("/{file_id}") async def delete_file(session: Session, file_id: str) -> FileDeleted: """Delete a file.""" crud_file_object = CRUDFileObject(model=FileObject) - file_deleted = await crud_file_object.delete(file_id=file_id, client=session) + file_deleted = await crud_file_object.delete(db=session, id_=file_id) crud_file_bucket = CRUDFileBucket(model=UploadFile) await crud_file_bucket.delete(client=session, id_=file_id) - return file_deleted + return FileDeleted( + id=file_id, + object="file", + deleted=bool(file_deleted), + ) @router.get("/{file_id}/content") diff --git a/supabase/migrations/20240419164109_init-assistant.sql b/supabase/migrations/20240419164109_init-assistant.sql index cb3943ac7..fc4305d3a 100644 --- a/supabase/migrations/20240419164109_init-assistant.sql +++ b/supabase/migrations/20240419164109_init-assistant.sql @@ -2,7 +2,7 @@ create table assistant_objects ( id uuid primary key DEFAULT uuid_generate_v4(), - created_at bigint, + created_at bigint default extract(epoch from now()), description text, instructions text, metadata jsonb, diff --git a/supabase/migrations/20240422015807_init-files.sql b/supabase/migrations/20240422015807_init-files.sql index 25d8f92bf..e4050b267 100644 --- a/supabase/migrations/20240422015807_init-files.sql +++ b/supabase/migrations/20240422015807_init-files.sql @@ -3,7 +3,7 @@ create table file_objects ( id uuid primary key DEFAULT uuid_generate_v4(), bytes int, - created_at bigint, + created_at bigint default extract(epoch from now()), filename text, object text, purpose text, From 1f5c6510fbda2b8cc7c4c75b2ca941320875c85b Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 16 May 2024 12:29:01 -0400 Subject: [PATCH 61/73] a couple schema tweaks --- .../migrations/20240419164109_init-assistant.sql | 12 ++++++------ supabase/migrations/20240422015807_init-files.sql | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/supabase/migrations/20240419164109_init-assistant.sql b/supabase/migrations/20240419164109_init-assistant.sql index fc4305d3a..1127d7d58 100644 --- a/supabase/migrations/20240419164109_init-assistant.sql +++ b/supabase/migrations/20240419164109_init-assistant.sql @@ -2,15 +2,15 @@ create table assistant_objects ( id uuid primary key DEFAULT uuid_generate_v4(), - created_at bigint default extract(epoch from now()), - description text, + created_at bigint default extract(epoch from now()) not null, + description varchar(512), instructions text, metadata jsonb, - model text, - name text, - object text, + model varchar(255) not null, + name varchar(255), + object text check (object in ('assistant')), tools jsonb, - response_format text, + response_format jsonb, temperature float, tool_resources jsonb, top_p float diff --git a/supabase/migrations/20240422015807_init-files.sql b/supabase/migrations/20240422015807_init-files.sql index e4050b267..d371263c5 100644 --- a/supabase/migrations/20240422015807_init-files.sql +++ b/supabase/migrations/20240422015807_init-files.sql @@ -3,9 +3,9 @@ create table file_objects ( id uuid primary key DEFAULT uuid_generate_v4(), bytes int, - created_at bigint default extract(epoch from now()), + created_at bigint default extract(epoch from now()) not null, filename text, - object text, + object text check (object in ('file')), purpose text, status text, status_details text From 99619ae6ae6d3a92a606ec51d839647a5ab07a88 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 16 May 2024 13:14:32 -0400 Subject: [PATCH 62/73] fix some shindig int he file upload endpoint --- .../routers/openai/assistants.py | 5 ++-- src/leapfrogai_api/routers/openai/files.py | 27 +++++-------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 670e98d86..45c764817 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -1,6 +1,5 @@ """OpenAI Compliant Assistants API Router.""" -import time from fastapi import HTTPException, APIRouter, status from openai.types.beta import Assistant, AssistantDeleted from openai.types.beta.assistant import ToolResources @@ -24,8 +23,8 @@ async def create_assistant( try: assistant = Assistant( - id="", # Leave blank to have Postgres generate a UUID - created_at=int(time.time()), + id="", # This is set by the database to prevent conflicts + created_at=0, # This is set by the database name=request.name, description=request.description, instructions=request.instructions, diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index 6ada5c445..f80f9d959 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -1,6 +1,5 @@ """OpenAI Compliant Files API Router.""" -import time from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from openai.types import FileDeleted, FileObject from leapfrogai_api.backend.types import ListFilesResponse, UploadFileRequest @@ -22,7 +21,7 @@ async def upload_file( file_object = FileObject( id="", # This is set by the database to prevent conflicts bytes=request.file.size, - created_at=int(time.time()), + created_at=0, # This is set by the database to prevent conflicts filename=request.file.filename, object="file", # Per OpenAI Spec this should always be file purpose="assistants", # we only support assistants for now @@ -34,35 +33,21 @@ async def upload_file( status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to parse file" ) from exc - try: - crud_file_object = CRUDFileObject(model=FileObject) - file_object = await crud_file_object.create(db=session, object_=file_object) - - if not file_object: - raise HTTPException(status_code=500, detail="Failed to create file object") - - except Exception as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to store file object", - ) from exc - try: crud_file_bucket = CRUDFileBucket(model=UploadFile) await crud_file_bucket.upload( client=session, file=request.file, id_=file_object.id ) + + crud_file_object = CRUDFileObject(model=FileObject) + return await crud_file_object.create(db=session, object_=file_object) + except Exception as exc: - crud_file_object.delete( - db=session, id_=file_object.id - ) # If we fail to upload the file, delete the file object raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to store file in bucket", + detail="Failed to store file", ) from exc - return file_object - @router.get("") async def list_files(session: Session) -> ListFilesResponse: From 15a551927ce837fb22ca7920d3b96b303d33e76b Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 16 May 2024 13:23:19 -0400 Subject: [PATCH 63/73] update modify assistant to clarify what can be modified --- .../routers/openai/assistants.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 45c764817..277856049 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -77,7 +77,33 @@ async def retrieve_assistant(session: Session, assistant_id: str) -> Assistant | async def modify_assistant( session: Session, assistant_id: str, request: ModifyAssistantRequest ) -> Assistant: - """Modify an assistant.""" + """ + Modify an assistant. + + Args: + session (Session): The database session. + assistant_id (str): The ID of the assistant to modify. + request (ModifyAssistantRequest): The request object containing the updated assistant information. + + Returns: + Assistant: The modified assistant. + + Raises: + HTTPException: If the assistant is not found or if there is an error parsing the request. + + Note: + The following attributes of the assistant can be updated: + - name + - description + - instructions + - model + - tools + - tool_resources + - temperature + - top_p + - metadata + - response_format + """ crud_assistant = CRUDAssistant(model=Assistant) old_assistant = await crud_assistant.get(db=session, id_=assistant_id) From 08f9a9bdbee5b9ba187ebb6c25c3f3f4b8b5375c Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Thu, 16 May 2024 13:50:20 -0400 Subject: [PATCH 64/73] need to create file_object before adding to bucket so we have file_id --- src/leapfrogai_api/routers/openai/files.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index f80f9d959..f96627ba9 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -21,7 +21,7 @@ async def upload_file( file_object = FileObject( id="", # This is set by the database to prevent conflicts bytes=request.file.size, - created_at=0, # This is set by the database to prevent conflicts + created_at=123, # This is set by the database to prevent conflicts filename=request.file.filename, object="file", # Per OpenAI Spec this should always be file purpose="assistants", # we only support assistants for now @@ -33,16 +33,20 @@ async def upload_file( status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to parse file" ) from exc + crud_file_object = CRUDFileObject(model=FileObject) + try: + file_object = await crud_file_object.create(db=session, object_=file_object) + crud_file_bucket = CRUDFileBucket(model=UploadFile) await crud_file_bucket.upload( client=session, file=request.file, id_=file_object.id ) - crud_file_object = CRUDFileObject(model=FileObject) - return await crud_file_object.create(db=session, object_=file_object) + return file_object except Exception as exc: + crud_file_object.delete(db=session, id_=file_object.id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to store file", From 0e592d54a168505b66b13fd83f67cd5ed84959b6 Mon Sep 17 00:00:00 2001 From: gharvey Date: Thu, 16 May 2024 15:10:22 -0700 Subject: [PATCH 65/73] Adds supabase env variables to api deployment --- packages/api/chart/templates/api/deployment.yaml | 8 ++++++++ packages/api/chart/values.yaml | 3 +++ packages/api/zarf.yaml | 2 ++ uds-bundles/dev/cpu/uds-config.yaml | 3 +++ uds-bundles/dev/gpu/uds-config.yaml | 3 +++ uds-bundles/latest/cpu/uds-config.yaml | 3 +++ uds-bundles/latest/gpu/uds-config.yaml | 3 +++ 7 files changed, 25 insertions(+) diff --git a/packages/api/chart/templates/api/deployment.yaml b/packages/api/chart/templates/api/deployment.yaml index b8a4097bd..556c390f6 100644 --- a/packages/api/chart/templates/api/deployment.yaml +++ b/packages/api/chart/templates/api/deployment.yaml @@ -45,6 +45,14 @@ spec: value: "*.toml" - name: PORT value: "{{ .Values.api.port }}" + - name: SUPABASE_URL + value: "{{ .Values.supabase.url }}" + - name: SUPABASE_SERVICE_KEY + valueFrom: + secretKeyRef: + name: supabase-bootstrap-jwt + key: service-key + optional: true ports: - containerPort: 8080 livenessProbe: diff --git a/packages/api/chart/values.yaml b/packages/api/chart/values.yaml index 0e6ab7a15..fdd1aa2e5 100644 --- a/packages/api/chart/values.yaml +++ b/packages/api/chart/values.yaml @@ -4,6 +4,9 @@ image: # x-release-please-end kiwigridTag: 1.23.3 +supabase: + url: "https://supabase-kong.###ZARF_VAR_HOSTED_DOMAIN###" + api: replicas: 1 port: 8080 diff --git a/packages/api/zarf.yaml b/packages/api/zarf.yaml index 675451d0f..19ed21cfb 100644 --- a/packages/api/zarf.yaml +++ b/packages/api/zarf.yaml @@ -18,6 +18,8 @@ variables: - name: EXPOSE_OPENAPI_SCHEMA default: "false" description: "Flag to expose the OpenAPI schema for debugging." + - name: HOSTED_DOMAIN + default: "uds.dev" components: - name: leapfrogai diff --git a/uds-bundles/dev/cpu/uds-config.yaml b/uds-bundles/dev/cpu/uds-config.yaml index 7c90089f0..b21fc101d 100644 --- a/uds-bundles/dev/cpu/uds-config.yaml +++ b/uds-bundles/dev/cpu/uds-config.yaml @@ -24,6 +24,9 @@ variables: - "https://ai.uds.dev" hosted_domain: "uds.dev" + leapfrogai-api: + hosted_domain: "uds.dev" + leapfrogai-ui: subdomain: ai domain: uds.dev diff --git a/uds-bundles/dev/gpu/uds-config.yaml b/uds-bundles/dev/gpu/uds-config.yaml index 9b5ca998e..7bd1a9b89 100644 --- a/uds-bundles/dev/gpu/uds-config.yaml +++ b/uds-bundles/dev/gpu/uds-config.yaml @@ -29,6 +29,9 @@ variables: - "https://ai.uds.dev" hosted_domain: "uds.dev" + leapfrogai-api: + hosted_domain: "uds.dev" + leapfrogai-ui: subdomain: ai domain: uds.dev diff --git a/uds-bundles/latest/cpu/uds-config.yaml b/uds-bundles/latest/cpu/uds-config.yaml index 7c90089f0..b21fc101d 100644 --- a/uds-bundles/latest/cpu/uds-config.yaml +++ b/uds-bundles/latest/cpu/uds-config.yaml @@ -24,6 +24,9 @@ variables: - "https://ai.uds.dev" hosted_domain: "uds.dev" + leapfrogai-api: + hosted_domain: "uds.dev" + leapfrogai-ui: subdomain: ai domain: uds.dev diff --git a/uds-bundles/latest/gpu/uds-config.yaml b/uds-bundles/latest/gpu/uds-config.yaml index 9b5ca998e..7bd1a9b89 100644 --- a/uds-bundles/latest/gpu/uds-config.yaml +++ b/uds-bundles/latest/gpu/uds-config.yaml @@ -29,6 +29,9 @@ variables: - "https://ai.uds.dev" hosted_domain: "uds.dev" + leapfrogai-api: + hosted_domain: "uds.dev" + leapfrogai-ui: subdomain: ai domain: uds.dev From d57451ecbf619ed64adf69b45605394786f10d05 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Fri, 17 May 2024 14:30:26 -0400 Subject: [PATCH 66/73] fix a couple minor issues --- src/leapfrogai_api/routers/openai/assistants.py | 3 ++- src/leapfrogai_api/routers/openai/files.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 277856049..cd0906a1f 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -141,7 +141,8 @@ async def modify_assistant( ) except FileNotFoundError as exc: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Assistant not found" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update assistant", ) from exc diff --git a/src/leapfrogai_api/routers/openai/files.py b/src/leapfrogai_api/routers/openai/files.py index f96627ba9..dea7cfec9 100644 --- a/src/leapfrogai_api/routers/openai/files.py +++ b/src/leapfrogai_api/routers/openai/files.py @@ -21,7 +21,7 @@ async def upload_file( file_object = FileObject( id="", # This is set by the database to prevent conflicts bytes=request.file.size, - created_at=123, # This is set by the database to prevent conflicts + created_at=0, # This is set by the database to prevent conflicts filename=request.file.filename, object="file", # Per OpenAI Spec this should always be file purpose="assistants", # we only support assistants for now From 8294ece8764f4afc38f2032f33dcff56539f2b00 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Fri, 17 May 2024 16:16:23 -0400 Subject: [PATCH 67/73] fixes the ninja Aleks error --- src/leapfrogai_api/backend/types.py | 9 +- .../routers/openai/assistants.py | 52 ++++++-- tests/integration/api/test_assistants.py | 122 ++++++++++++++---- 3 files changed, 145 insertions(+), 38 deletions(-) diff --git a/src/leapfrogai_api/backend/types.py b/src/leapfrogai_api/backend/types.py index df3c6c3dc..18d531122 100644 --- a/src/leapfrogai_api/backend/types.py +++ b/src/leapfrogai_api/backend/types.py @@ -5,7 +5,8 @@ from pydantic import BaseModel from fastapi import UploadFile, Form, File from openai.types import FileObject -from openai.types.beta import Assistant +from openai.types.beta import Assistant, AssistantTool +from openai.types.beta.assistant import ToolResources ########## # GENERIC @@ -250,10 +251,8 @@ class CreateAssistantRequest(BaseModel): name: str | None = "Froggy Assistant" description: str | None = "A helpful assistant." instructions: str | None = "You are a helpful assistant." - tools: list[dict[Literal["type"], Literal["file_search"]]] | None = [ - {"type": "file_search"} - ] # This is all we support right now - tool_resources: object | None = {} + tools: list[AssistantTool] | None = [] # This is all we support right now + tool_resources: ToolResources | None = ToolResources() metadata: object | None = {} temperature: float | None = 1.0 top_p: float | None = 1.0 diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index cd0906a1f..645123fd3 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -1,16 +1,15 @@ """OpenAI Compliant Assistants API Router.""" -from fastapi import HTTPException, APIRouter, status +from fastapi import APIRouter, HTTPException, status from openai.types.beta import Assistant, AssistantDeleted -from openai.types.beta.assistant import ToolResources +from openai.types.beta.assistant import ToolResources, ToolResourcesCodeInterpreter from leapfrogai_api.backend.types import ( CreateAssistantRequest, ListAssistantsResponse, ModifyAssistantRequest, ) -from leapfrogai_api.routers.supabase_session import Session -from leapfrogai_api.utils.openai_util import validate_tools_typed_dict from leapfrogai_api.data.crud_assistant_object import CRUDAssistant +from leapfrogai_api.routers.supabase_session import Session router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) @@ -21,6 +20,23 @@ async def create_assistant( ) -> Assistant: """Create an assistant.""" + if request.tools is not None: + for tool in request.tools: + if tool.type not in ["file_search"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported tool type: {tool.type}", + ) + + if request.tool_resources is not None: + for tool_resource in request.tool_resources: + if tool_resource is ToolResourcesCodeInterpreter: + if tool_resource["file_ids"] is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Code interpreter tool is not supported", + ) + try: assistant = Assistant( id="", # This is set by the database to prevent conflicts @@ -30,8 +46,8 @@ async def create_assistant( instructions=request.instructions, model=request.model, object="assistant", - tools=validate_tools_typed_dict(request.tools), - tool_resources=ToolResources.model_validate(request.tool_resources), + tools=request.tools, + tool_resources=request.tool_resources, temperature=request.temperature, top_p=request.top_p, metadata=request.metadata, @@ -104,6 +120,24 @@ async def modify_assistant( - metadata - response_format """ + + if request.tools is not None: + for tool in request.tools: + if tool.type not in ["file_search"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported tool type: {tool.type}", + ) + + if request.tool_resources is not None: + for tool_resource in request.tool_resources: + if tool_resource is ToolResourcesCodeInterpreter: + if tool_resource["file_ids"] is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Code interpreter tool is not supported", + ) + crud_assistant = CRUDAssistant(model=Assistant) old_assistant = await crud_assistant.get(db=session, id_=assistant_id) @@ -121,10 +155,12 @@ async def modify_assistant( instructions=request.instructions or old_assistant.instructions, model=request.model or old_assistant.model, object="assistant", - tools=validate_tools_typed_dict(request.tools) or old_assistant.tools, + tools=request.tools or old_assistant.tools, tool_resources=ToolResources.model_validate(request.tool_resources) or old_assistant.tool_resources, - temperature=request.temperature or old_assistant.temperature, + temperature=float(request.temperature) + if request.temperature is not None + else old_assistant.temperature, top_p=request.top_p or old_assistant.top_p, metadata=request.metadata or old_assistant.metadata, response_format=request.response_format or old_assistant.response_format, diff --git a/tests/integration/api/test_assistants.py b/tests/integration/api/test_assistants.py index e0604c6ec..01515a42b 100644 --- a/tests/integration/api/test_assistants.py +++ b/tests/integration/api/test_assistants.py @@ -15,6 +15,38 @@ client = TestClient(router) +starting_assistant = Assistant( + id="", + created_at=0, + name="test", + description="test", + instructions="test", + model="test", + object="assistant", + tools=[{"type": "file_search"}], + tool_resources={}, + temperature=1.0, + top_p=1.0, + metadata={}, + response_format="auto", +) + +modified_assistant = Assistant( + id="", + created_at=0, + name="test1", + description="test1", + instructions="test1", + model="test1", + object="assistant", + tools=[{"type": "file_search"}], + tool_resources={}, + temperature=0, + top_p=0.1, + metadata={}, + response_format="auto", +) + @pytest.fixture(scope="session", autouse=True) def create_assistant(): @@ -23,21 +55,56 @@ def create_assistant(): global assistant_response # pylint: disable=global-statement request = CreateAssistantRequest( - model="test", - name="test", - description="test", - instructions="test", - tools=[{"type": "file_search"}], - tool_resources={}, - metadata={}, - temperature=1.0, - top_p=1.0, - response_format="auto", + model=starting_assistant.model, + name=starting_assistant.name, + description=starting_assistant.description, + instructions=starting_assistant.instructions, + tools=starting_assistant.tools, + tool_resources=starting_assistant.tool_resources, + metadata=starting_assistant.metadata, + temperature=starting_assistant.temperature, + top_p=starting_assistant.top_p, + response_format=starting_assistant.response_format, ) assistant_response = client.post("/openai/v1/assistants", json=request.model_dump()) +@pytest.mark.xfail +def test_code_interpreter_fails(): + """Test creating an assistant with a code interpreter tool. Requires a running Supabase instance.""" + request = CreateAssistantRequest( + model=modified_assistant.model, + name=modified_assistant.name, + description=modified_assistant.description, + instructions=modified_assistant.instructions, + tools=[{"type": "code_interpreter"}], + tool_resources=modified_assistant.tool_resources, + metadata=modified_assistant.metadata, + temperature=modified_assistant.temperature, + top_p=modified_assistant.top_p, + response_format=modified_assistant, + ) + + assistant_fail_response = client.post( + "/openai/v1/assistants", json=request.model_dump() + ) + + assert assistant_fail_response.status_code is status.HTTP_400_BAD_REQUEST + assert ( + assistant_fail_response.json()["detail"] + == "Unsupported tool type: code_interpreter" + ) + + modify_response = client.post( + f"/openai/v1/assistants/{assistant_response.json()['id']}", + json=request.model_dump(), + ) + + assert modify_response.status_code is status.HTTP_400_BAD_REQUEST + assert modify_response.json()["detail"] == "Unsupported tool type: code_interpreter" + + def test_create(): """Test creating an assistant. Requires a running Supabase instance.""" assert assistant_response.status_code is status.HTTP_200_OK @@ -68,6 +135,9 @@ def test_list(): def test_modify(): """Test modifying an assistant. Requires a running Supabase instance.""" + + global modified_assistant # pylint: disable=global-statement + assistant_id = assistant_response.json()["id"] get_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_response.status_code is status.HTTP_200_OK @@ -75,19 +145,17 @@ def test_modify(): get_response.json() ), f"Get endpoint should return Assistant {assistant_id}." - modified_name = "test1" - request = ModifyAssistantRequest( - model="test1", - name=modified_name, - description="test1", - instructions="test1", - tools=[{"type": "file_search"}], - tool_resources={}, - metadata={}, - temperature=1.0, - top_p=1.0, - response_format="auto", + model=modified_assistant.model, + name=modified_assistant.name, + description=modified_assistant.description, + instructions=modified_assistant.instructions, + tools=modified_assistant.tools, + tool_resources=modified_assistant.tool_resources, + metadata=modified_assistant.metadata, + temperature=modified_assistant.temperature, + top_p=modified_assistant.top_p, + response_format=modified_assistant.response_format, ) modify_response = client.post( @@ -98,9 +166,13 @@ def test_modify(): assert Assistant.model_validate( modify_response.json() ), "Should return a Assistant." - assert ( - modify_response.json()["name"] == modified_name - ), f"Assistant {assistant_id} should be modified via modify endpoint." + + modified_assistant.id = modify_response.json()["id"] + modified_assistant.created_at = modify_response.json()["created_at"] + + assert modified_assistant == Assistant( + **modify_response.json() + ), f"Modify endpoint should return modified Assistant {assistant_id}." get_modified_response = client.get(f"/openai/v1/assistants/{assistant_id}") assert get_modified_response.status_code is status.HTTP_200_OK From c1ac5f8ee2218a5074309240f46bdcf6189648f9 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Fri, 17 May 2024 16:17:23 -0400 Subject: [PATCH 68/73] removed the openai utilpre-commitpre-commitpre-commit! --- src/leapfrogai_api/utils/openai_util.py | 38 ------------------------- 1 file changed, 38 deletions(-) delete mode 100644 src/leapfrogai_api/utils/openai_util.py diff --git a/src/leapfrogai_api/utils/openai_util.py b/src/leapfrogai_api/utils/openai_util.py deleted file mode 100644 index f8e1ec88a..000000000 --- a/src/leapfrogai_api/utils/openai_util.py +++ /dev/null @@ -1,38 +0,0 @@ -"""This module contains utility functions for interacting with OpenAI API.""" - -from openai.types.beta import ( - CodeInterpreterTool, - FileSearchTool, - FunctionTool, - AssistantTool, -) - -tool_mapping = { - "code_interpreter": CodeInterpreterTool, - "file_search": FileSearchTool, - "function_tool": FunctionTool, -} - - -def validate_tools_typed_dict(data: list[dict]) -> list[AssistantTool]: - """Validate a tool typed dict.""" - - max_supported_tools = 128 # OpenAI sets this to 128 - - if len(data) > max_supported_tools: - raise ValueError("Too many tools specified.") - - tool_instance = [] - - for tool_data in data: - if "type" not in tool_data: - raise ValueError("Tool type not specified.") - - tool_type = tool_data["type"] - if tool_type not in tool_mapping: - raise ValueError(f"Unknown tool type: {tool_type}") - - tool_class = tool_mapping[tool_type] - tool_instance.append(tool_class(**tool_data)) - - return tool_instance From f50ada9812fac48b3e9c23c24e66ff4ae4274e54 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 20 May 2024 13:48:58 -0400 Subject: [PATCH 69/73] changing config.yaml ignore to be specific to API (there is one in packages/text-embeddings that is tracked) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 090ed4703..5e0f60a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ build/ .ruff_cache .branches .temp -config.yaml +src/leapfrogai_api/config.yaml # local model and tokenizer files *.bin From eac365e9bb387baf36c3691a5548bb1a37882c64 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 20 May 2024 17:30:29 -0400 Subject: [PATCH 70/73] addressing some comments --- .../routers/openai/assistants.py | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 645123fd3..9798274b1 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -13,6 +13,8 @@ router = APIRouter(prefix="/openai/v1/assistants", tags=["openai/assistants"]) +supported_tools = ["file_search"] + @router.post("") async def create_assistant( @@ -20,22 +22,25 @@ async def create_assistant( ) -> Assistant: """Create an assistant.""" - if request.tools is not None: - for tool in request.tools: - if tool.type not in ["file_search"]: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported tool type: {tool.type}", - ) - - if request.tool_resources is not None: - for tool_resource in request.tool_resources: - if tool_resource is ToolResourcesCodeInterpreter: - if tool_resource["file_ids"] is not None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Code interpreter tool is not supported", - ) + if request.tools and ( + unsupported_tool := next( + (tool for tool in request.tools if tool.type not in supported_tools), None + ) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported tool type: {unsupported_tool.type}", + ) + + if request.tool_resources and any( + isinstance(tool_resource, ToolResourcesCodeInterpreter) + and tool_resource.get("file_ids") + for tool_resource in request.tool_resources + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Code interpreter tool is not supported", + ) try: assistant = Assistant( @@ -121,22 +126,25 @@ async def modify_assistant( - response_format """ - if request.tools is not None: - for tool in request.tools: - if tool.type not in ["file_search"]: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported tool type: {tool.type}", - ) - - if request.tool_resources is not None: - for tool_resource in request.tool_resources: - if tool_resource is ToolResourcesCodeInterpreter: - if tool_resource["file_ids"] is not None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Code interpreter tool is not supported", - ) + if request.tools and ( + unsupported_tool := next( + (tool for tool in request.tools if tool.type not in supported_tools), None + ) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported tool type: {unsupported_tool.type}", + ) + + if request.tool_resources and any( + isinstance(tool_resource, ToolResourcesCodeInterpreter) + and tool_resource.get("file_ids") + for tool_resource in request.tool_resources + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Code interpreter tool is not supported", + ) crud_assistant = CRUDAssistant(model=Assistant) From 08e918242cec585c7b08d85e9ced6860c016f17a Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 20 May 2024 17:45:57 -0400 Subject: [PATCH 71/73] addressed the last comment --- src/leapfrogai_api/routers/openai/assistants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 9798274b1..995cb8134 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -148,12 +148,12 @@ async def modify_assistant( crud_assistant = CRUDAssistant(model=Assistant) - old_assistant = await crud_assistant.get(db=session, id_=assistant_id) - if old_assistant is None: + if not (old_assistant := await crud_assistant.get(db=session, id_=assistant_id)): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Assistant not found" ) + try: new_assistant = Assistant( id=assistant_id, From 3ee46d5787f2da3914d20f82be032a9282666988 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 20 May 2024 17:46:23 -0400 Subject: [PATCH 72/73] missed some lint --- src/leapfrogai_api/routers/openai/assistants.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index 995cb8134..fa4ad6cb4 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -153,7 +153,6 @@ async def modify_assistant( status_code=status.HTTP_400_BAD_REQUEST, detail="Assistant not found" ) - try: new_assistant = Assistant( id=assistant_id, From 19ee1fe77616e48546d0f8c42a1dd9719d4be802 Mon Sep 17 00:00:00 2001 From: Gregory Horvath Date: Mon, 20 May 2024 18:03:58 -0400 Subject: [PATCH 73/73] addressed comment --- .../routers/openai/assistants.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/leapfrogai_api/routers/openai/assistants.py b/src/leapfrogai_api/routers/openai/assistants.py index fa4ad6cb4..420fd0f2c 100644 --- a/src/leapfrogai_api/routers/openai/assistants.py +++ b/src/leapfrogai_api/routers/openai/assistants.py @@ -157,20 +157,24 @@ async def modify_assistant( new_assistant = Assistant( id=assistant_id, created_at=old_assistant.created_at, - name=request.name or old_assistant.name, - description=request.description or old_assistant.description, - instructions=request.instructions or old_assistant.instructions, - model=request.model or old_assistant.model, + name=getattr(request, "name", old_assistant.name), + description=getattr(request, "description", old_assistant.description), + instructions=getattr(request, "instructions", old_assistant.instructions), + model=getattr(request, "model", old_assistant.model), object="assistant", - tools=request.tools or old_assistant.tools, - tool_resources=ToolResources.model_validate(request.tool_resources) + tools=getattr(request, "tools", old_assistant.tools), + tool_resources=ToolResources.model_validate( + getattr(request, "tool_resources", None) + ) or old_assistant.tool_resources, - temperature=float(request.temperature) - if request.temperature is not None - else old_assistant.temperature, - top_p=request.top_p or old_assistant.top_p, - metadata=request.metadata or old_assistant.metadata, - response_format=request.response_format or old_assistant.response_format, + temperature=float( + getattr(request, "temperature", old_assistant.temperature) + ), + top_p=getattr(request, "top_p", old_assistant.top_p), + metadata=getattr(request, "metadata", old_assistant.metadata), + response_format=getattr( + request, "response_format", old_assistant.response_format + ), ) except Exception as exc: raise HTTPException(