diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2e117bf --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +docs/*.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df4b45e..8d10b8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,12 @@ - Pull requests should only be merged once all checks pass - The repo uses Black for formatting Python code, Prettier for formatting Markdown, Pyright for type-checking Python, and a few other tools +- To generate reference documentation: + - Add/update docstrings in the codebase. If you are adding a new class/function, add + it's name to `documented_items` in `docs/generate_api_reference.py` + - run `cd docs;python generate_api_reference.py` + - [Internal only] Copy the contents of `api_reference.md` to the reference page in + README. - To run the CI checks locally: - `pip install pre-commit` - `pre-commit run --all` (or `pre-commit install` to install the pre-commit hook) diff --git a/docs/api_reference.md b/docs/api_reference.md new file mode 100644 index 0000000..080e577 --- /dev/null +++ b/docs/api_reference.md @@ -0,0 +1,190 @@ + + +The following it the API reference for the [fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes that you used `import fastapi as fp`. + +## `fp.PoeBot` + +The class that you use to define your bot behavior. Once you define your PoeBot class, you +pass it to `make_app` to create a FastAPI app that serves your bot. + +#### Parameters: +- `path`: This is the path at which your bot is served. By default, it's set to "/" +but this is something you can adjust. This is especially useful if you want to serve +multiple bots from one server. +- `access_key`: This is the access key for your bot and when provided is used to validate +that the requests are coming from a trusted source. This access key should be the same +one that you provide when integrating your bot with Poe at: +https://poe.com/create_bot?server=1. You can also set this to None but certain features like +file output that mandate an `access_key` will not be available for your bot. +- `concat_attachments_to_message`: A flag to decide whether to parse out content from +attachments and concatenate it to the conversation message. This is set to `True` by default +and we recommend leaving on since it allows your bot to comprehend attachments uploaded by +users by default. + +### `PoeBot.get_response` + +Override this to define your bot's response given a user query. + +### `PoeBot.get_response_with_context` + +A version of `get_response` that also includes the request context information. By +default, this will call `get_response`. + +### `PoeBot.get_settings` + +Override this to define your bot's settings. + +### `PoeBot.get_settings_with_context` + +A version of `get_settings` that also includes the request context information. By +default, this will call `get_settings`. + +### `PoeBot.on_feedback` + +Override this to record feedback from the user. + +### `PoeBot.on_feedback_with_context` + +A version of `on_feedback` that also includes the request context information. By +default, this will call `on_feedback`. + +### `PoeBot.on_error` + +Override this to record errors from the Poe server. + +### `PoeBot.on_error_with_context` + +A version of `on_error` that also includes the request context information. By +default, this will call `on_error`. + +### `PoeBot.post_message_attachment` + +Used to output an attachment in your bot's response. + +#### Parameters: +- `message_id`: The message id associated with the current QueryRequest object. +**Important**: This must be the request that is currently being handled by +get_response. Attempting to attach files to previously handled requests will fail. +- `download_url`: A url to the file to be attached to the message. +- `file_data`: The contents of the file to be uploaded. This should be a +bytes-like or file object. +- `filename`: The name of the file to be attached. + +**Note**: You need to provide either the `download_url` or both of `file_data` and +`filename`. + +### `PoeBot.concat_attachment_content_to_message_body` + +Concatenate received attachment file content into the message body. This will be called +by default if `concat_attachments_to_message` is set to `True` but can also be used +manually if needed. + + + +## `fp.make_app` + +Create an app object for your bot(s). + +#### Parameters: +- `bot`: A bot object or a list of bot objects if you want to host multiple bots on one server. +- `access_key`: The access key to use. If not provided, the server tries to read +the POE_ACCESS_KEY environment variable. If that is not set, the server will +refuse to start, unless `allow_without_key` is True. If multiple bots are provided, +the access key must be provided as part of the bot object. +- `api_key`: The previous name for `access_key`. This is not to be confused with the `api_key` +param needed by `stream_request`. This param is deprecated and will be removed in a future +version. +- `allow_without_key`: If True, the server will start even if no access key is provided. +Requests will not be checked against any key. If an access key is provided, it is still checked. +- `app`: A FastAPI app instance. If provided, the app will be configured with the provided bots, +access keys, and other settings. If not provided, a new FastAPI application instance will be +created and configured. + + + +## `fp.run` + +Serve a poe bot using a FastAPI app. This function should be used when you are running the +bot locally. The arguments are the same as they are for `make_app`. + + + +## `fp.stream_request` + +The Entry point for the Bot Query API. This API allows you to use other bots on Poe for +inference in response to a user message. For more details, checkout: +https://creator.poe.com/docs/accessing-other-bots-on-poe + +#### Parameters: +- `request`: A QueryRequest object representing a query from Poe. This object also includes +information needed to identify the user for compute point usage. +- `bot_name`: The bot you want to invoke. +- `api_key`: Your Poe API key, available at poe.com/api_key. You will need this in case you are +trying to use this function from a script/shell. Note that if an `api_key` is provided, +compute points will be charged on the account corresponding to the `api_key`. + + + +## `fp.get_bot_response` + +Use this function to invoke another Poe bot from your shell. +#### Parameters: +- `messages`: A list of protocol messages representing your conversation +- `bot_name`: The bot that you want to invoke. +- `api_key`: Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key) + + + +## `fp.get_final_response` + +A helper function for the bot query API that waits for all the tokens and concatenates the full +response before returning. + +#### Parameters: +- `request`: A QueryRequest object representing a query from Poe. This object also includes +information needed to identify the user for compute point usage. +- `bot_name`: The bot you want to invoke. +- `api_key`: Your Poe API key, available at poe.com/api_key. You will need this in case you are +trying to use this function from a script/shell. Note that if an `api_key` is provided, +compute points will be charged on the account corresponding to the `api_key`. + + + +## `fp.PartialResponse` + +Representation of a (possibly partial) response from a bot. Yield this in +`PoeBot.get_response` or `PoeBot.get_response_with_context` to communicate your response to Poe. + +#### Parameters: +- `text`: The actual text you want to display to the user. Note that this should solely +be the text in the next token since Poe will automatically concatenate all tokens before +displaying the response to the user. +- `data`: Used to send arbitrary json data to Poe. This is currently only used for OpenAI +function calling. +- `is_suggested_reply`: Seting this to true will create a suggested reply with the provided +text value. +- `is_replace_response`: Setting this to true will clear out the previously displayed text +to the user and replace it with the provided text value. + + + +## `fp.ErrorResponse` + +Similar to `PartialResponse`. Yield this to communicate errors from your bot. + +#### Parameters: +- `allow_retry`: Whether or not to allow a user to retry on error. +- `error_type`: An enum indicating what error to display. + + + +## `fp.MetaResponse` + +Similar to `Partial Response`. Yield this to communicate `meta` events from server bots. + +#### Parameters: +- `suggested_replies`: Whether or not to enable suggested replies. +- `content_type`: Used to describe the format of the response. The currently supported values +are `text/plain` and `text/markdown`. +- `refetch_settings`: Used to trigger a settings fetch request from Poe. A more robust way +to trigger this is documented at: https://creator.poe.com/docs/updating-bot-settings diff --git a/docs/generate_api_reference.py b/docs/generate_api_reference.py new file mode 100644 index 0000000..1fc40c5 --- /dev/null +++ b/docs/generate_api_reference.py @@ -0,0 +1,111 @@ +import inspect +import sys +import types +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Union + +sys.path.append("../src") +import fastapi_poe + +INITIAL_TEXT = """ + +The following it the API reference for the \ +[fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes \ +that you used `import fastapi as fp`. + +""" + + +@dataclass +class DocumentationData: + name: str + docstring: Optional[str] + data_type: str + children: List = field(default_factory=lambda: []) + + +def _unwrap_func(func_obj: Union[staticmethod, Callable]) -> Callable: + """Grab the underlying func_obj.""" + if isinstance(func_obj, staticmethod): + return _unwrap_func(func_obj.__func__) + return func_obj + + +def get_documentation_data( + *, module: types.ModuleType, documented_items: List[str] +) -> Dict[str, DocumentationData]: + data_dict = {} + for name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) or inspect.isfunction(obj) + ) and name in documented_items: + doc = inspect.getdoc(obj) + data_type = "class" if inspect.isclass(obj) else "function" + dd_obj = DocumentationData(name=name, docstring=doc, data_type=data_type) + + if inspect.isclass(obj): + children = [] + # for func_name, func_obj in inspect.getmembers(obj, inspect.isfunction): + for func_name, func_obj in obj.__dict__.items(): + if not inspect.isfunction(func_obj): + continue + if not func_name.startswith("_"): + func_obj = _unwrap_func(func_obj) + func_doc = inspect.getdoc(func_obj) + children.append( + DocumentationData( + name=func_name, docstring=func_doc, data_type="function" + ) + ) + dd_obj.children = children + data_dict[name] = dd_obj + return data_dict + + +def generate_documentation( + *, + data_dict: Dict[str, DocumentationData], + documented_items: List[str], + output_filename: str, +) -> None: + # reset the file first + with open(output_filename, "w") as f: + f.write("") + + with open(output_filename, "w") as f: + f.write(INITIAL_TEXT) + + for item in documented_items: + item_data = data_dict[item] + f.write(f"## `fp.{item_data.name}`\n\n") + f.write(f"{item_data.docstring}\n\n") + for child in item_data.children: + if not child.docstring: + continue + f.write(f"### `{item}.{child.name}`\n\n") + f.write(f"{child.docstring}\n\n") + f.write("\n\n") + + +# Specify the names of classes and functions to document +documented_items = [ + "PoeBot", + "make_app", + "run", + "stream_request", + "get_bot_response", + "get_final_response", + "PartialResponse", + "ErrorResponse", + "MetaResponse", +] + +data_dict = get_documentation_data( + module=fastapi_poe, documented_items=documented_items +) +output_filename = "api_reference.md" +generate_documentation( + data_dict=data_dict, + documented_items=documented_items, + output_filename=output_filename, +) diff --git a/pyproject.toml b/pyproject.toml index 80061ea..9117f98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ target-version = ['py37'] skip-magic-trailing-comma = true [tool.ruff] -select = [ +lint.select = [ "F", "E", "I", # import sorting @@ -69,7 +69,7 @@ select = [ "PERF101", # unnecessary list() calls that can be replaced with literals ] -ignore = [ +lint.ignore = [ "B008", # do not perform function calls in argument defaults "ANN101", # missing type annotation for self in method "ANN102", # missing type annotation for cls in classmethod diff --git a/src/fastapi_poe/base.py b/src/fastapi_poe/base.py index e1fcbf8..534b188 100644 --- a/src/fastapi_poe/base.py +++ b/src/fastapi_poe/base.py @@ -105,6 +105,27 @@ async def http_exception_handler(request: Request, ex: Exception) -> Response: @dataclass class PoeBot: + """ + + The class that you use to define your bot behavior. Once you define your PoeBot class, you + pass it to `make_app` to create a FastAPI app that serves your bot. + + #### Parameters: + - `path`: This is the path at which your bot is served. By default, it's set to "/" + but this is something you can adjust. This is especially useful if you want to serve + multiple bots from one server. + - `access_key`: This is the access key for your bot and when provided is used to validate + that the requests are coming from a trusted source. This access key should be the same + one that you provide when integrating your bot with Poe at: + https://poe.com/create_bot?server=1. You can also set this to None but certain features like + file output that mandate an `access_key` will not be available for your bot. + - `concat_attachments_to_message`: A flag to decide whether to parse out content from + attachments and concatenate it to the conversation message. This is set to `True` by default + and we recommend leaving on since it allows your bot to comprehend attachments uploaded by + users by default. + + """ + path: str = "/" # Path where this bot will be exposed access_key: Optional[str] = None # Access key for this bot concat_attachments_to_message: bool = ( @@ -113,45 +134,70 @@ class PoeBot: # Override these for your bot + async def get_response( + self, request: QueryRequest + ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent]]: + """Override this to define your bot's response given a user query.""" + yield self.text_event("hello") + async def get_response_with_context( self, request: QueryRequest, context: RequestContext ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent]]: + """ + + A version of `get_response` that also includes the request context information. By + default, this will call `get_response`. + + """ + yield self.text_event("hello") async for event in self.get_response(request): yield event - async def get_response( - self, request: QueryRequest - ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent]]: - """Override this to return a response to user queries.""" - yield self.text_event("hello") + async def get_settings(self, setting: SettingsRequest) -> SettingsResponse: + """Override this to define your bot's settings.""" + return SettingsResponse() async def get_settings_with_context( self, setting: SettingsRequest, context: RequestContext ) -> SettingsResponse: + """ + + A version of `get_settings` that also includes the request context information. By + default, this will call `get_settings`. + + """ settings = await self.get_settings(setting) return settings - async def get_settings(self, setting: SettingsRequest) -> SettingsResponse: - """Override this to return non-standard settings.""" - return SettingsResponse() + async def on_feedback(self, feedback_request: ReportFeedbackRequest) -> None: + """Override this to record feedback from the user.""" + pass async def on_feedback_with_context( self, feedback_request: ReportFeedbackRequest, context: RequestContext ) -> None: + """ + + A version of `on_feedback` that also includes the request context information. By + default, this will call `on_feedback`. + + """ await self.on_feedback(feedback_request) - async def on_feedback(self, feedback_request: ReportFeedbackRequest) -> None: - """Override this to record feedback from the user.""" - pass + async def on_error(self, error_request: ReportErrorRequest) -> None: + """Override this to record errors from the Poe server.""" + logger.error(f"Error from Poe server: {error_request}") async def on_error_with_context( self, error_request: ReportErrorRequest, context: RequestContext ) -> None: - await self.on_error(error_request) + """ - async def on_error(self, error_request: ReportErrorRequest) -> None: - """Override this to record errors from the Poe server.""" - logger.error(f"Error from Poe server: {error_request}") + A version of `on_error` that also includes the request context information. By + default, this will call `on_error`. + + """ + await self.on_error(error_request) # Helpers for generating responses def __post_init__(self) -> None: @@ -199,6 +245,23 @@ async def post_message_attachment( content_type: Optional[str] = None, is_inline: bool = False, ) -> AttachmentUploadResponse: + """ + + Used to output an attachment in your bot's response. + + #### Parameters: + - `message_id`: The message id associated with the current QueryRequest object. + **Important**: This must be the request that is currently being handled by + get_response. Attempting to attach files to previously handled requests will fail. + - `download_url`: A url to the file to be attached to the message. + - `file_data`: The contents of the file to be uploaded. This should be a + bytes-like or file object. + - `filename`: The name of the file to be attached. + + **Note**: You need to provide either the `download_url` or both of `file_data` and + `filename`. + + """ if message_id is None: raise InvalidParameterError("message_id parameter is required") @@ -313,7 +376,13 @@ async def _process_pending_attachment_requests( def concat_attachment_content_to_message_body( self, query_request: QueryRequest ) -> QueryRequest: - """Concat received attachment file content into message content body.""" + """ + + Concatenate received attachment file content into the message body. This will be called + by default if `concat_attachments_to_message` is set to `True` but can also be used + manually if needed. + + """ last_message = query_request.query[-1] concatenated_content = last_message.content for attachment in last_message.attachments: @@ -593,7 +662,26 @@ def make_app( allow_without_key: bool = False, app: Optional[FastAPI] = None, ) -> FastAPI: - """Create an app object. Arguments are as for run().""" + """ + + Create an app object for your bot(s). + + #### Parameters: + - `bot`: A bot object or a list of bot objects if you want to host multiple bots on one server. + - `access_key`: The access key to use. If not provided, the server tries to read + the POE_ACCESS_KEY environment variable. If that is not set, the server will + refuse to start, unless `allow_without_key` is True. If multiple bots are provided, + the access key must be provided as part of the bot object. + - `api_key`: The previous name for `access_key`. This is not to be confused with the `api_key` + param needed by `stream_request`. This param is deprecated and will be removed in a future + version. + - `allow_without_key`: If True, the server will start even if no access key is provided. + Requests will not be checked against any key. If an access key is provided, it is still checked. + - `app`: A FastAPI app instance. If provided, the app will be configured with the provided bots, + access keys, and other settings. If not provided, a new FastAPI application instance will be + created and configured. + + """ if app is None: app = FastAPI() app.add_exception_handler(RequestValidationError, http_exception_handler) @@ -651,21 +739,10 @@ def run( app: Optional[FastAPI] = None, ) -> None: """ - Run a Poe bot server using FastAPI. - :param bot: The bot object or a list of bot objects. - :param access_key: The access key to use. If not provided, the server tries to read - the POE_ACCESS_KEY environment variable. If that is not set, the server will - refuse to start, unless *allow_without_key* is True. If multiple bots are provided, - the access key must be provided as part of the bot object. - :param api_key: The previous name of access_key. This param is deprecated and will be - removed in a future version - :param allow_without_key: If True, the server will start even if no access key - is provided. Requests will not be checked against any key. If an access key - is provided, it is still checked. - :param app: A FastAPI app instance. If provided, app will be configured with the - provided bots, access keys, and other settings. If not provided, a new FastAPI - application instance will be created and configured. + Serve a poe bot using a FastAPI app. This function should be used when you are running the + bot locally. The arguments are the same as they are for `make_app`. + """ app = make_app( diff --git a/src/fastapi_poe/client.py b/src/fastapi_poe/client.py index 99a70b3..bb0815e 100644 --- a/src/fastapi_poe/client.py +++ b/src/fastapi_poe/client.py @@ -307,6 +307,21 @@ async def stream_request( retry_sleep_time: float = 0.5, base_url: str = "https://api.poe.com/bot/", ) -> AsyncGenerator[BotMessage, None]: + """ + + The Entry point for the Bot Query API. This API allows you to use other bots on Poe for + inference in response to a user message. For more details, checkout: + https://creator.poe.com/docs/accessing-other-bots-on-poe + + #### Parameters: + - `request`: A QueryRequest object representing a query from Poe. This object also includes + information needed to identify the user for compute point usage. + - `bot_name`: The bot you want to invoke. + - `api_key`: Your Poe API key, available at poe.com/api_key. You will need this in case you are + trying to use this function from a script/shell. Note that if an `api_key` is provided, + compute points will be charged on the account corresponding to the `api_key`. + + """ tool_calls = None tool_results = None if tools is not None: @@ -499,6 +514,15 @@ def get_bot_response( base_url: str = "https://api.poe.com/bot/", session: Optional[httpx.AsyncClient] = None, ) -> AsyncGenerator[BotMessage, None]: + """ + + Use this function to invoke another Poe bot from your shell. + #### Parameters: + - `messages`: A list of protocol messages representing your conversation + - `bot_name`: The bot that you want to invoke. + - `api_key`: Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key) + + """ additional_params = {} # This is so that we don't have to redefine the default values for these params. if temperature is not None: @@ -542,7 +566,20 @@ async def get_final_response( retry_sleep_time: float = 0.5, base_url: str = "https://api.poe.com/bot/", ) -> str: - """Gets the final response from a Poe bot.""" + """ + + A helper function for the bot query API that waits for all the tokens and concatenates the full + response before returning. + + #### Parameters: + - `request`: A QueryRequest object representing a query from Poe. This object also includes + information needed to identify the user for compute point usage. + - `bot_name`: The bot you want to invoke. + - `api_key`: Your Poe API key, available at poe.com/api_key. You will need this in case you are + trying to use this function from a script/shell. Note that if an `api_key` is provided, + compute points will be charged on the account corresponding to the `api_key`. + + """ chunks: List[str] = [] async for message in stream_request( request, diff --git a/src/fastapi_poe/types.py b/src/fastapi_poe/types.py index bf00e7e..a51628f 100644 --- a/src/fastapi_poe/types.py +++ b/src/fastapi_poe/types.py @@ -104,7 +104,24 @@ class AttachmentUploadResponse(BaseModel): class PartialResponse(BaseModel): - """Representation of a (possibly partial) response from a bot.""" + """ + + Representation of a (possibly partial) response from a bot. Yield this in + `PoeBot.get_response` or `PoeBot.get_response_with_context` to communicate your response to Poe. + + #### Parameters: + - `text`: The actual text you want to display to the user. Note that this should solely + be the text in the next token since Poe will automatically concatenate all tokens before + displaying the response to the user. + - `data`: Used to send arbitrary json data to Poe. This is currently only used for OpenAI + function calling. + - `is_suggested_reply`: Seting this to true will create a suggested reply with the provided + text value. + - `is_replace_response`: Setting this to true will clear out the previously displayed text + to the user and replace it with the provided text value. + + + """ # These objects are usually instantiated in user code, so we # disallow extra fields to prevent mistakes. @@ -139,16 +156,35 @@ class PartialResponse(BaseModel): class ErrorResponse(PartialResponse): - """Communicate errors from server bots.""" + """ + + Similar to `PartialResponse`. Yield this to communicate errors from your bot. + + #### Parameters: + - `allow_retry`: Whether or not to allow a user to retry on error. + - `error_type`: An enum indicating what error to display. + + """ allow_retry: bool = False error_type: Optional[ErrorType] = None class MetaResponse(PartialResponse): - """Communicate 'meta' events from server bots.""" + """ + + Similar to `Partial Response`. Yield this to communicate `meta` events from server bots. + + #### Parameters: + - `suggested_replies`: Whether or not to enable suggested replies. + - `content_type`: Used to describe the format of the response. The currently supported values + are `text/plain` and `text/markdown`. + - `refetch_settings`: Used to trigger a settings fetch request from Poe. A more robust way + to trigger this is documented at: https://creator.poe.com/docs/updating-bot-settings + + """ - linkify: bool = True + linkify: bool = True # deprecated suggested_replies: bool = True content_type: ContentType = "text/markdown" refetch_settings: bool = False