Skip to content
This repository has been archived by the owner on Nov 13, 2024. It is now read-only.

Commit

Permalink
add /api and version to app.py (#169)
Browse files Browse the repository at this point in the history
* add /api and version to app.py

* restructre api models to versionized directories

* split to routers and set up app init funciton

* fixed docs/schema

* add version to /health

* edit base url for tests to API_VESION

* merge main

* chage api_base on cli chat

* edit comment

* set default for default url path

* revert to 0.0.0.0 on uvicorn

* fix import in text
  • Loading branch information
miararoy authored Nov 14, 2023
1 parent 020a3de commit bf05ba3
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 38 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,12 @@ This will open a similar chat interface window, but will show both the RAG and n

### Migrating an existing OpenAI application to **Canopy**

If you already have an application that uses the OpenAI API, you can migrate it to **Canopy** by simply changing the API endpoint to `http://host:port/context` as follows:
If you already have an application that uses the OpenAI API, you can migrate it to **Canopy** by simply changing the API endpoint to `http://host:port/v1`, for example with the default configuration:

```python
import openai

openai.api_base = "http://host:port/"
openai.api_base = "http://localhost:8000/v1"

# now you can use the OpenAI API as usual
```
Expand All @@ -212,7 +212,7 @@ or without global state change:
```python
import openai

openai_response = openai.Completion.create(..., api_base="http://host:port/")
openai_response = openai.Completion.create(..., api_base="http://localhost:8000/v1")
```

### Running Canopy server in production
Expand Down
32 changes: 18 additions & 14 deletions src/canopy_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,18 @@

from canopy import __version__

from canopy_server.app import start as start_server
from canopy_server.app import start as start_server, API_VERSION
from .cli_spinner import Spinner
from canopy_server.api_models import ChatDebugInfo
from canopy_server.models.v1.api_models import ChatDebugInfo


load_dotenv()
if os.getenv("OPENAI_API_KEY"):
openai.api_key = os.getenv("OPENAI_API_KEY")

spinner = Spinner()
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
DEFAULT_SERVER_URL = f"http://localhost:8000/{API_VERSION}"
spinner = Spinner()


def check_server_health(url: str):
Expand Down Expand Up @@ -171,8 +172,9 @@ def cli(ctx):


@cli.command(help="Check if canopy server is running and healthy.")
@click.option("--url", default="http://localhost:8000",
help="Canopy's server url. Defaults to http://localhost:8000")
@click.option("--url", default=DEFAULT_SERVER_URL,
help=("Canopy's server url. "
f"Defaults to {DEFAULT_SERVER_URL}"))
def health(url):
check_server_health(url)
click.echo(click.style("Canopy server is healthy!", fg="green"))
Expand Down Expand Up @@ -432,8 +434,9 @@ def _chat(
help="Print additional debugging information")
@click.option("--rag/--no-rag", default=True,
help="Compare RAG-infused Chatbot with vanilla LLM",)
@click.option("--chat-server-url", default="http://localhost:8000",
help="URL of the Canopy server to use. Defaults to http://localhost:8000")
@click.option("--chat-server-url", default=DEFAULT_SERVER_URL,
help=("URL of the Canopy server to use."
f" Defaults to {DEFAULT_SERVER_URL}"))
def chat(chat_server_url, rag, debug, stream):
check_server_health(chat_server_url)
note_msg = (
Expand Down Expand Up @@ -488,7 +491,7 @@ def chat(chat_server_url, rag, debug, stream):
history=history_with_pinecone,
message=message,
stream=stream,
api_base=urljoin(chat_server_url, "/context"),
api_base=chat_server_url,
print_debug_info=debug,
)

Expand Down Expand Up @@ -527,7 +530,7 @@ def chat(chat_server_url, rag, debug, stream):
)
)
@click.option("--host", default="0.0.0.0",
help="Hostname or ip address to bind the server to. Defaults to 0.0.0.0")
help="Hostname or address to bind the server to. Defaults to 0.0.0.0")
@click.option("--port", default=8000,
help="TCP port to bind the server to. Defaults to 8000")
@click.option("--reload/--no-reload", default=False,
Expand Down Expand Up @@ -580,8 +583,9 @@ def start(host: str, port: str, reload: bool,
"""
)
)
@click.option("url", "--url", default="http://localhost:8000",
help="URL of the Canopy server to use. Defaults to http://localhost:8000")
@click.option("url", "--url", default=DEFAULT_SERVER_URL,
help=("URL of the Canopy server to use. "
f"Defaults to {DEFAULT_SERVER_URL}"))
def stop(url):
if os.name != "nt":
# Check if the server was started using Gunicorn
Expand Down Expand Up @@ -643,17 +647,17 @@ def api_docs(url):
if generated_docs:
import json
from canopy_server._redocs_template import HTML_TEMPLATE
from canopy_server.app import app
from canopy_server.app import app, _init_routes
# generate docs

_init_routes(app)
filename = "canopy-api-docs.html"
msg = f"Generating docs to {filename}"
click.echo(click.style(msg, fg="green"))
with open(filename, "w") as fd:
print(HTML_TEMPLATE % json.dumps(app.openapi()), file=fd)
webbrowser.open('file://' + os.path.realpath(filename))
else:
webbrowser.open('http://localhost:8000/redoc')
webbrowser.open(urljoin(url, "redoc"))


if __name__ == "__main__":
Expand Down
52 changes: 39 additions & 13 deletions src/canopy_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
from starlette.concurrency import run_in_threadpool
from sse_starlette.sse import EventSourceResponse

from fastapi import FastAPI, HTTPException, Body
from fastapi import (
FastAPI,
HTTPException,
Body,
APIRouter
)
import uvicorn
from typing import cast, Union

Expand All @@ -27,7 +32,7 @@
ChatResponse,
)
from canopy.models.data_models import Context, UserMessage
from .api_models import (
from .models.v1.api_models import (
ChatRequest,
ContextQueryRequest,
ContextUpsertRequest,
Expand Down Expand Up @@ -64,8 +69,10 @@
You can find your free trial OpenAI API key https://platform.openai.com/account/api-keys. You might need to log in or register for OpenAI services.
""" # noqa: E501

API_VERSION = "v1"

app = FastAPI(
# Global variables - Application
app: FastAPI = FastAPI(
title="Canopy API",
description=APP_DESCRIPTION,
version=__version__,
Expand All @@ -74,16 +81,22 @@
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
},
)
openai_api_router = APIRouter()
context_api_router = APIRouter(prefix="/context")
application_router = APIRouter(tags=["Application"])

# Global variables - Engines
context_engine: ContextEngine
chat_engine: ChatEngine
kb: KnowledgeBase
llm: BaseLLM

# Global variables - Logging
logger: logging.Logger


@app.post(
"/context/chat/completions",
@openai_api_router.post(
"/chat/completions",
response_model=None,
responses={500: {"description": "Failed to chat with Canopy"}}, # noqa: E501
)
Expand Down Expand Up @@ -126,8 +139,8 @@ def stringify_content(response: StreamingChatResponse):
raise HTTPException(status_code=500, detail=f"Internal Service Error: {str(e)}")


@app.post(
"/context/query",
@context_api_router.post(
"/query",
response_model=ContextResponse,
responses={
500: {"description": "Failed to query the knowledge base or build the context"}
Expand Down Expand Up @@ -156,8 +169,8 @@ async def query(
raise HTTPException(status_code=500, detail=f"Internal Service Error: {str(e)}")


@app.post(
"/context/upsert",
@context_api_router.post(
"/upsert",
response_model=SuccessUpsertResponse,
responses={500: {"description": "Failed to upsert documents"}},
)
Expand All @@ -183,8 +196,8 @@ async def upsert(
raise HTTPException(status_code=500, detail=f"Internal Service Error: {str(e)}")


@app.post(
"/context/delete",
@context_api_router.post(
"/delete",
response_model=SuccessDeleteResponse,
responses={500: {"description": "Failed to delete documents"}},
)
Expand All @@ -204,7 +217,7 @@ async def delete(
raise HTTPException(status_code=500, detail=f"Internal Service Error: {str(e)}")


@app.get(
@application_router.get(
"/health",
response_model=HealthStatus,
responses={500: {"description": "Failed to connect to Pinecone or LLM"}},
Expand Down Expand Up @@ -236,7 +249,7 @@ async def health_check() -> HealthStatus:
return HealthStatus(pinecone_status="OK", llm_status="OK")


@app.get("/shutdown")
@application_router.get("/shutdown")
async def shutdown() -> ShutdownResponse:
"""
__WARNING__: Experimental method.
Expand Down Expand Up @@ -267,6 +280,19 @@ async def shutdown() -> ShutdownResponse:
async def startup():
_init_logging()
_init_engines()
_init_routes(app)


def _init_routes(app):
# Include the application level router (health, shutdown, ...)
app.include_router(application_router, include_in_schema=False)
app.include_router(application_router, prefix=f"/{API_VERSION}")
# Include the API without version == latest
app.include_router(context_api_router, include_in_schema=False)
app.include_router(openai_api_router, include_in_schema=False)
# Include the API version in the path, API_VERSION should be the latest version.
app.include_router(context_api_router, prefix=f"/{API_VERSION}", tags=["Context"])
app.include_router(openai_api_router, prefix=f"/{API_VERSION}", tags=["LLM"])


def _init_logging():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from canopy.models.data_models import Messages, Query, Document

# TODO: consider separating these into modules: Chat, Context, Application, etc.


class ChatRequest(BaseModel):
model: str = Field(
Expand Down
29 changes: 21 additions & 8 deletions tests/e2e/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@

from canopy.knowledge_base import KnowledgeBase

from canopy_server.app import app
from canopy_server.api_models import (HealthStatus, ContextUpsertRequest,
ContextQueryRequest, )
from canopy_server.app import app, API_VERSION
from canopy_server.models.v1.api_models import (
HealthStatus,
ContextUpsertRequest,
ContextQueryRequest)
from .. import Tokenizer

upsert_payload = ContextUpsertRequest(
Expand Down Expand Up @@ -63,6 +65,7 @@ def client(knowledge_base, index_name):
os.environ["INDEX_NAME"] = index_name
Tokenizer.clear()
with TestClient(app) as client:
client.base_url = f"{client.base_url}/{API_VERSION}"
yield client
if index_name_before:
os.environ["INDEX_NAME"] = index_name_before
Expand Down Expand Up @@ -95,7 +98,9 @@ def test_health(client):

def test_upsert(client):
# Upsert a document to the index
upsert_response = client.post("/context/upsert", json=upsert_payload.dict())
upsert_response = client.post(
"/context/upsert",
json=upsert_payload.dict())
assert upsert_response.is_success


Expand All @@ -114,7 +119,9 @@ def test_query(client):
max_tokens=100,
)

query_response = client.post("/context/query", json=query_payload.dict())
query_response = client.post(
"/context/query",
json=query_payload.dict())
assert query_response.is_success

query_response = query_response.json()
Expand Down Expand Up @@ -142,7 +149,9 @@ def test_chat_required_params(client):
}
]
}
chat_response = client.post("/context/chat/completions", json=chat_payload)
chat_response = client.post(
"/chat/completions",
json=chat_payload)
assert chat_response.is_success
chat_response_as_json = chat_response.json()
assert chat_response_as_json["choices"][0]["message"]["role"] == "assistant"
Expand Down Expand Up @@ -170,7 +179,9 @@ def test_chat_openai_additional_params(client):
"stop": "stop string",
"top_p": 0.5,
}
chat_response = client.post("/context/chat/completions", json=chat_payload)
chat_response = client.post(
"/chat/completions",
json=chat_payload)
assert chat_response.is_success
chat_response_as_json = chat_response.json()
assert chat_response_as_json["choices"][0]["message"]["role"] == "assistant"
Expand All @@ -189,7 +200,9 @@ def test_delete(client, knowledge_base):
delete_payload = {
"document_ids": doc_ids
}
delete_response = client.post("/context/delete", json=delete_payload)
delete_response = client.post(
"/context/delete",
json=delete_payload)
assert delete_response.is_success

assert_vector_ids_not_exist(vector_ids, knowledge_base)

0 comments on commit bf05ba3

Please sign in to comment.