Repository: poe-platform/fastapi_poe Branch: main Commit: 41ffd02e16f2 Files: 26 Total size: 255.9 KB Directory structure: gitextract_eeb9sykf/ ├── .flake8 ├── .github/ │ ├── CODEOWNERS │ ├── pull_request_template.md │ └── workflows/ │ ├── lint.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs/ │ ├── api_reference.md │ └── generate_api_reference.py ├── pyproject.toml ├── src/ │ └── fastapi_poe/ │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── py.typed │ ├── sync_utils.py │ ├── templates.py │ └── types.py └── tests/ ├── test_base.py ├── test_client.py ├── test_sync_utils.py └── test_types.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .flake8 ================================================ [flake8] max-line-length = 100 extend-immutable-calls = Depends, fastapi.Depends, fastapi.params.Depends ================================================ FILE: .github/CODEOWNERS ================================================ # This file is used to automatically assign reviewers to PRs # For more information see: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners * @poe-platform/fastapi_poe_reviewers ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Brief description of changes ## Changes Made - Detailed change 1 - Detailed change 2 ## Testing Done - Test scenario 1 - Test scenario 2 ## Version - Updated to version 0.0.49 ## Breaking Changes - List any breaking changes ## Checklist - [ ] Tests added - [ ] Documentation updated - [ ] Version bumped - [ ] Changes tested locally ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [push, pull_request] jobs: precommit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up latest Python uses: actions/setup-python@v4 with: python-version: "3.11" - name: Run pre-commit hooks uses: pre-commit/action@v3.0.0 pyright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up latest Python uses: actions/setup-python@v4 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[dev] - uses: jakebailey/pyright-action@v1 tests: name: unit tests timeout-minutes: 10 runs-on: ${{ matrix.os }} strategy: matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] python-version: ["3.9", "3.10", "3.11", "3.12"] fail-fast: false steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip cache-dependency-path: pyproject.toml - run: pip install -e ".[dev]" - run: pytest tests/ ================================================ FILE: .github/workflows/publish.yml ================================================ # Based on # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ name: Publish Python distribution to PyPI on: push jobs: build: name: Build distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: Install pypa/build run: python3 -m pip install --user build - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python distribution to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/fastapi-poe permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 ================================================ FILE: .gitignore ================================================ __pycache__/ .DS_Store .coverage venv/ ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.2.2 hooks: - id: ruff args: [--fix] - repo: https://github.com/psf/black rev: 24.2.0 hooks: - id: black language_version: python3.11 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier ================================================ FILE: .prettierignore ================================================ docs/*.md ================================================ FILE: .prettierrc.yaml ================================================ proseWrap: always printWidth: 88 endOfLine: auto ================================================ FILE: CONTRIBUTING.md ================================================ # General - All changes should be made through pull requests - 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, follow the instructions in docs/generate_api_reference.py - To run the CI checks locally: - `pip install pre-commit` - `pre-commit run --all` (or `pre-commit install` to install the pre-commit hook) # Releases To release a new version of `fastapi_poe`, do the following: - Make a PR updating the version number in `pyproject.toml` (example: https://github.com/poe-platform/fastapi_poe/pull/2) - Merge it once CI passes - Go to https://github.com/poe-platform/fastapi_poe/releases/new and make a new release (note this link works only if you have commit access to this repository) - The tag should be of the form "0.0.X". - Fill in the release notes with some description of what changed since the last release. - [GitHub Actions](https://github.com/poe-platform/fastapi_poe/actions) will generate the release artefacts and upload them to PyPI - You can check [PyPI](https://pypi.org/project/fastapi-poe/) to verify that the release went through. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # fastapi_poe An implementation of the [Poe protocol](https://creator.poe.com/docs/poe-protocol-specification) using FastAPI. ### Installation Install the package from PyPI: ```bash pip install fastapi-poe ``` ### Write your own bot This package can also be used as a base to write your own bot. You can inherit from `PoeBot` to make a bot: ```python import fastapi_poe as fp class EchoBot(fp.PoeBot): async def get_response(self, request: fp.QueryRequest): last_message = request.query[-1].content yield fp.PartialResponse(text=last_message) if __name__ == "__main__": fp.run(EchoBot(), allow_without_key=True) ``` Now, run your bot using `python `. - In a different terminal, run [ngrok](https://ngrok.com/) to make it publicly accessible. - Use the publicly accessible url to integrate your bot with [Poe](https://poe.com/create_bot?server=1) ### Enable authentication Poe servers send requests containing Authorization HTTP header in the format "Bearer "; the access key is configured in the bot settings page. To validate that the request is from the Poe servers, you can either set the environment variable POE_ACCESS_KEY or pass the parameter access_key in the run function like: ```python if __name__ == "__main__": fp.run(EchoBot(), access_key=) ``` ## Samples Check out our starter code [repository](https://github.com/poe-platform/server-bot-quick-start) for some examples you can use to get started with bot development. ================================================ FILE: docs/api_reference.md ================================================ The following is the API reference for the [fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes that you used `import fastapi_poe 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` (`str = "/"`): 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` (`Optional[str] = None`): 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. - `should_insert_attachment_messages` (`bool = True`): A flag to decide whether to parse out content from attachments and insert them as messages into the conversation. 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. - `concat_attachments_to_message` (`bool = False`): **DEPRECATED**: Please set `should_insert_attachment_messages` instead. ### `PoeBot.get_response` Override this to define your bot's response given a user query. #### Parameters: - `request` (`QueryRequest`): an object representing the chat response request from Poe. This will contain information about the chat state among other things. #### Returns: - `AsyncIterable[PartialResponse]`: objects representing your response to the Poe servers. This is what gets displayed to the user. Example usage: ```python async def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]: last_message = request.query[-1].content yield fp.PartialResponse(text=last_message) ``` ### `PoeBot.get_response_with_context` A version of `get_response` that also includes the request context information. By default, this will call `get_response`. #### Parameters: - `request` (`QueryRequest`): an object representing the chat response request from Poe. This will contain information about the chat state among other things. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: - `AsyncIterable[Union[PartialResponse, ErrorResponse]]`: objects representing your response to the Poe servers. This is what gets displayed to the user. ### `PoeBot.get_settings` Override this to define your bot's settings. #### Parameters: - `setting` (`SettingsRequest`): An object representing the settings request. #### Returns: - `SettingsResponse`: An object representing the settings you want to use for your bot. ### `PoeBot.get_settings_with_context` A version of `get_settings` that also includes the request context information. By default, this will call `get_settings`. #### Parameters: - `setting` (`SettingsRequest`): An object representing the settings request. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: - `SettingsResponse`: An object representing the settings you want to use for your bot. ### `PoeBot.on_feedback` Override this to record feedback from the user. #### Parameters: - `feedback_request` (`ReportFeedbackRequest`): An object representing the Feedback request from Poe. This is sent out when a user provides feedback on a response on your bot. #### Returns: `None` ### `PoeBot.on_feedback_with_context` A version of `on_feedback` that also includes the request context information. By default, this will call `on_feedback`. #### Parameters: - `feedback_request` (`ReportFeedbackRequest`): An object representing a feedback request from Poe. This is sent out when a user provides feedback on a response on your bot. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: `None` ### `PoeBot.on_reaction_with_context` Override this to record a reaction from the user. This also includes the request context. #### Parameters: - `reaction_request` (`ReportReactionRequest`): An object representing a reaction request from Poe. This is sent out when a user provides reaction on a response on your bot. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: `None` ### `PoeBot.on_error` Override this to record errors from the Poe server. #### Parameters: - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe. This is sent out when the Poe server runs into an issue processing the response from your bot. #### Returns: `None` ### `PoeBot.on_error_with_context` A version of `on_error` that also includes the request context information. By default, this will call `on_error`. #### Parameters: - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe. This is sent out when the Poe server runs into an issue processing the response from your bot. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: `None` ### `PoeBot.post_message_attachment` Used to output an attachment in your bot's response. #### Parameters: - `message_id` (`Identifier`): The message id associated with the current QueryRequest. - `download_url` (`Optional[str] = None`): A url to the file to be attached to the message. - `download_filename` (`Optional[str] = None`): A filename to be used when storing the downloaded attachment. If not set, the filename from the `download_url` is used. - `file_data` (`Optional[Union[bytes, BinaryIO]] = None`): The contents of the file to be uploaded. This should be a bytes-like or file object. - `filename` (`Optional[str] = None`): The name of the file to be attached. - `access_key` (`str`): **DEPRECATED**: Please set the access_key when creating the Bot object instead. #### Returns: - `AttachmentUploadResponse` **Note**: You need to provide either the `download_url` or both of `file_data` and `filename`. ### `PoeBot.concat_attachment_content_to_message_body` **DEPRECATED**: This method is deprecated. Use `insert_attachment_messages` instead. 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. #### Parameters: - `query_request` (`QueryRequest`): the request object from Poe. #### Returns: - `QueryRequest`: the request object after the attachments are unpacked and added to the message body. ### `PoeBot.insert_attachment_messages` Insert messages containing the contents of each user attachment right before the last user message. This ensures the bot can consider all relevant information when generating a response. This will be called by default if `should_insert_attachment_messages` is set to `True` but can also be used manually if needed. #### Parameters: - `query_request` (`QueryRequest`): the request object from Poe. #### Returns: - `QueryRequest`: the request object after the attachments are unpacked and added to the message body. ### `PoeBot.make_prompt_author_role_alternated` Concatenate consecutive messages from the same author into a single message. This is useful for LLMs that require role alternation between user and bot messages. #### Parameters: - `protocol_messages` (`Sequence[ProtocolMessage]`): the messages to make alternated. #### Returns: - `Sequence[ProtocolMessage]`: the modified messages. ### `PoeBot.capture_cost` Used to capture variable costs for monetized and eligible bot creators. Visit https://creator.poe.com/docs/creator-monetization for more information. #### Parameters: - `request` (`QueryRequest`): The currently handled QueryRequest object. - `amounts` (`Union[list[CostItem], CostItem]`): The to be captured amounts. #### Returns: `None` ### `PoeBot.authorize_cost` Used to authorize a cost for monetized and eligible bot creators. Visit https://creator.poe.com/docs/creator-monetization for more information. #### Parameters: - `request` (`QueryRequest`): The currently handled QueryRequest object. - `amounts` (`Union[list[CostItem], CostItem]`): The to be authorized amounts. #### Returns: `None` --- ## `fp.make_app` Create an app object for your bot(s). #### Parameters: - `bot` (`Union[PoeBot, Sequence[PoeBot]]`): A bot object or a list of bot objects if you want to host multiple bots on one server. - `access_key` (`str = ""`): 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. - `bot_name` (`str = ""`): The name of the bot as it appears on poe.com. - `api_key` (`str = ""`): **DEPRECATED**: Please set the access_key when creating the Bot object instead. - `allow_without_key` (`bool = False`): 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` (`Optional[FastAPI] = None`): 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. #### Returns: - `FastAPI`: A FastAPI app configured to serve your bot when run. --- ## `fp.run` Serve a poe bot using a FastAPI app. This function should be used when you are running the bot locally. The parameters are the same as they are for `make_app`. #### Returns: `None` --- ## `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/server-bots-functional-guides#accessing-other-bots-on-poe #### Parameters: - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object also includes information needed to identify the user for compute point usage. - `bot_name` (`str`): The bot you want to invoke. - `api_key` (`str = ""`): 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`. - tools: (`Optional[list[ToolDefinition]] = None`): A list of ToolDefinition objects describing the functions you have. This is used for OpenAI function calling. - tool_executables: (`Optional[list[Callable]] = None`): A list of functions corresponding to the ToolDefinitions. This is used for OpenAI function calling. When this is set, the LLM-suggested tools will automatically run once, before passing the results back to the LLM for a final response. --- ## `fp.get_bot_response` Use this function to invoke another Poe bot from your shell. #### Parameters: - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation. - `bot_name` (`str`): The bot that you want to invoke. - `api_key` (`str`): Your Poe API key. Available at [poe.com/api_key](https://poe.com/api_key) - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects describing the functions you have. This is used for OpenAI function calling. - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding to the ToolDefinitions. This is used for OpenAI function calling. - `temperature` (`Optional[float] = None`): The temperature to use for the bot. - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt. - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot. - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot. - `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is mainly for internal testing and is not expected to be changed. - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot. --- ## `fp.get_bot_response_sync` This function wraps the async generator `fp.get_bot_response` and returns partial responses synchronously. For asynchronous streaming, or integration into an existing event loop, use `fp.get_bot_response` directly. #### Parameters: - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation. - `bot_name` (`str`): The bot that you want to invoke. - `api_key` (`str`): Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key) - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects describing the functions you have. This is used for OpenAI function calling. - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding to the ToolDefinitions. This is used for OpenAI function calling. - `temperature` (`Optional[float] = None`): The temperature to use for the bot. - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt. - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot. - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot. - `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is mainly for internal testing and is not expected to be changed. - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot. --- ## `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` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object also includes information needed to identify the user for compute point usage. - `bot_name` (`str`): The bot you want to invoke. - `api_key` (`str = ""`): 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.upload_file` Upload a file (raw bytes *or* via URL) to Poe and receive an Attachment object that can be returned directly from a bot or stored for later use. #### Parameters: - `file` (`Optional[Union[bytes, BinaryIO]] = None`): The file to upload. - `file_url` (`Optional[str] = None`): The URL of the file to upload. - `file_name` (`Optional[str] = None`): The name of the file to upload. Required if `file` is provided as raw bytes. - `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. This can also be the `access_key` if called from a Poe server bot. #### Returns: - `Attachment`: An Attachment object representing the uploaded file. --- ## `fp.upload_file_sync` This is a synchronous wrapper around the async `upload_file`. --- ## `fp.QueryRequest` Request parameters for a query request. #### Fields: - `query` (`list[ProtocolMessage]`): list of message representing the current state of the chat. - `user_id` (`Identifier`): an anonymized identifier representing a user. This is persistent for subsequent requests from that user. - `conversation_id` (`Identifier`): an identifier representing a chat. This is persistent for subsequent request for that chat. - `message_id` (`Identifier`): an identifier representing a message. - `access_key` (`str = ""`): contains the access key defined when you created your bot on Poe. - `temperature` (`float | None = None`): Temperature input to be used for model inference. - `skip_system_prompt` (`bool = False`): Whether to use any system prompting or not. - `logit_bias` (`dict[str, float] = {}`) - `stop_sequences` (`list[str] = []`) - `language_code` (`str = "en"`): BCP 47 language code of the user's client. - `bot_query_id` (`str = ""`): an identifier representing a bot query. - `users` (`list[User] = []`): list of users in the chat. --- ## `fp.ProtocolMessage` A message as used in the Poe protocol. #### Fields: - `role` (`Literal["system", "user", "bot", "tool"]`): Message sender role. - `message_type` (`Optional[MessageType] = None`): Type of the message. - `sender_id` (`Optional[str]`): Sender ID of the message. This is deprecated, use `sender` instead. - `sender` (`Optional[Sender] = None`): Sender of the message. - `content` (`str`): Content of the message. - `parameters` (`dict[str, Any] = {}`): Parameters for the message. - `content_type` (`ContentType="text/markdown"`): Content type of the message. - `timestamp` (`int = 0`): Timestamp of the message. - `message_id` (`str = ""`): Message ID for the message. - `feedback` (`list[MessageFeedback] = []`): Feedback for the message. - `attachments` (`list[Attachment] = []`): Attachments for the message. - `metadata` (`Optional[str] = None`): Metadata associated with the message. - `referenced_message` (`Optional["ProtocolMessage"] = None`): Message referenced by this message (if any). - `reactions` (`list[MessageReaction] = []`): Reactions to the message. --- ## `fp.Sender` Sender of a message. #### Fields: - `id` (`Optional[Identifier] = None`): An anonymized identifier representing the sender. - `name` (`Optional[str] = None`): The name of the sender. If sender is a bot, this will be the name of the bot. If sender is a user, this will be the name of the user if user name is available for this chat. Typically, user name is only available in a chat of multiple users. Please note that a user can change their name anytime and different users with different `id` can share the same name. --- ## `fp.User` User in a chat. #### Fields: - `id` (`Identifier`): An anonymized identifier representing a user. - `name` (`Optional[str] = None`): The name of the user if user name is available for this chat. Typically, user name is only available in a chat of multiple users. Please note that a user can change their name anytime and different users with different `id` can share the same name. --- ## `fp.MessageReaction` Reaction to a message. #### Fields: - `user_id` (`Identifier`): An anonymized identifier representing the user who reacted to the message. - `reaction` (`str`): The reaction to the message. --- ## `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. #### Fields: - `text` (`str`): 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` (`Optional[dict[str, Any]]`): Used to send arbitrary json data to Poe. This is currently only used for OpenAI function calling. - `is_suggested_reply` (`bool = False`): Setting this to true will create a suggested reply with the provided text value. - `is_replace_response` (`bool = False`): 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. #### Fields: - `allow_retry` (`bool = True`): Whether or not to allow a user to retry on error. - `error_type` (`Optional[ErrorType] = None`): An enum indicating what error to display. --- ## `fp.MetaResponse` Similar to `Partial Response`. Yield this to communicate `meta` events from server bots. #### Fields: - `suggested_replies` (`bool = False`): Whether or not to enable suggested replies. - `content_type` (`ContentType = "text/markdown"`): Used to describe the format of the response. The currently supported values are `text/plain` and `text/markdown`. - `refetch_settings` (`bool = False`): Used to trigger a settings fetch request from Poe. A more robust way to trigger this is documented at: https://creator.poe.com/docs/server-bots/updating-bot-settings --- ## `fp.DataResponse` A response that contains arbitrary data to attach to the bot response. This data can be retrieved in later requests to the bot within the same chat. Note that only the final DataResponse object in the stream will be attached to the bot response. #### Fields: - `metadata` (`str`): String of data to attach to the bot response. --- ## `fp.AttachmentUploadResponse` The result of a post_message_attachment request. #### Fields: - `attachment_url` (`Optional[str]`): The URL of the attachment. - `mime_type` (`Optional[str]`): The MIME type of the attachment. - `inline_ref` (`Optional[str]`): The inline reference of the attachment. if post_message_attachment is called with is_inline=False, this will be None. --- ## `fp.SettingsRequest` Request parameters for a settings request. Currently, this contains no fields but this might get updated in the future. --- ## `fp.SettingsResponse` An object representing your bot's response to a settings object. #### Fields: - `response_version` (`int = 2`): Different Poe Protocol versions use different default settings values. When provided, Poe will use the default values for the specified response version. If not provided, Poe will use the default values for response version 0. - `server_bot_dependencies` (`dict[str, int] = {}`): Information about other bots that your bot uses. This is used to facilitate the Bot Query API. - `allow_attachments` (`bool = True`): Whether to allow users to upload attachments to your bot. - `introduction_message` (`str = ""`): The introduction message to display to the users of your bot. - `expand_text_attachments` (`bool = True`): Whether to request parsed content/descriptions from text attachments with the query request. This content is sent through the new parsed_content field in the attachment dictionary. This change makes enabling file uploads much simpler. - `enable_image_comprehension` (`bool = False`): Similar to `expand_text_attachments` but for images. - `enforce_author_role_alternation` (`bool = False`): If enabled, Poe will concatenate messages so that they follow role alternation, which is a requirement for certain LLM providers like Anthropic. - `enable_multi_entity_prompting` (`bool = True`): If enabled, Poe will combine previous bot messages if there is a multientity context. - `parameter_controls` (`Optional[ParameterControls] = None`): Optional JSON object that defines interactive parameter controls. The object must contain an api_version and sections array. --- ## `fp.ReportFeedbackRequest` Request parameters for a report_feedback request. #### Fields: - `message_id` (`Identifier`) - `user_id` (`Identifier`) - `conversation_id` (`Identifier`) - `feedback_type` (`FeedbackType`) --- ## `fp.ReportReactionRequest` Request parameters for a report_reaction request. #### Fields: - `message_id` (`Identifier`) - `user_id` (`Identifier`) - `conversation_id` (`Identifier`) - `reaction` (`str`) --- ## `fp.ReportErrorRequest` Request parameters for a report_error request. #### Fields: - `message` (`str`) - `metadata` (`dict[str, Any]`) --- ## `fp.Attachment` Attachment included in a protocol message. #### Fields: - `url` (`str`): The download URL of the attachment. - `content_type` (`str`): The MIME type of the attachment. - `name` (`str`): The name of the attachment. - `inline_ref` (`Optional[str] = None`): Set this to make Poe render the attachment inline. You can then reference the attachment inline using ![title][inline_ref]. - `parsed_content` (`Optional[str] = None`): The parsed content of the attachment. --- ## `fp.MessageFeedback` Feedback for a message as used in the Poe protocol. #### Fields: - `type` (`FeedbackType`) - `reason` (`Optional[str]`) --- ## `fp.ToolDefinition` An object representing a tool definition used for OpenAI function calling. #### Fields: - `type` (`str`) - `function` (`FunctionDefinition`): Look at the source code for a detailed description of what this means. --- ## `fp.ToolCallDefinition` An object representing a tool call. This is returned as a response by the model when using OpenAI function calling. #### Fields: - `id` (`str`) - `type` (`str`) - `function` (`FunctionDefinition`): The function name (string) and arguments (JSON string). --- ## `fp.ToolResultDefinition` An object representing a function result. This is passed to the model in the last step when using OpenAI function calling. #### Fields: - `role` (`str`) - `name` (`str`) - `tool_call_id` (`str`) - `content` (`str`) ================================================ FILE: docs/generate_api_reference.py ================================================ """ - 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` - Install local version of fastapi_poe: `pip install -e .` - run `python3 generate_api_reference.py` - [Internal only] Copy the contents of `api_reference.md` to the reference page in README. """ import inspect import sys import types from dataclasses import dataclass, field from typing import Callable, Optional, Union sys.path.append("../src") import fastapi_poe INITIAL_TEXT = """ The following is the API reference for the \ [fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes \ that you used `import fastapi_poe 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) first = True for item in documented_items: if first is True: first = False else: f.write("---\n\n") 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_bot_response_sync", "get_final_response", "upload_file", "upload_file_sync", "QueryRequest", "ProtocolMessage", "Sender", "User", "MessageReaction", "PartialResponse", "ErrorResponse", "MetaResponse", "DataResponse", "AttachmentUploadResponse", "SettingsRequest", "SettingsResponse", "ReportFeedbackRequest", "ReportReactionRequest", "ReportErrorRequest", "Attachment", "MessageFeedback", "ToolDefinition", "ToolCallDefinition", "ToolResultDefinition", ] 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, ) ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "fastapi_poe" version = "0.0.83" authors = [ { name="Yusheng Ding", email="yding@quora.com" }, { name="Kris Yang", email="kryang@quora.com" }, { name="John Li", email="jli@quora.com" }, ] description = "A demonstration of the Poe protocol using FastAPI" readme = "README.md" requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ] dependencies = [ "fastapi", "sse-starlette>=2.2.1", "typing-extensions>=4.5.0", "uvicorn", "httpx", "httpx-sse", "pydantic>2", ] [project.optional-dependencies] dev = [ "pytest", "pytest-cov", "pytest-asyncio", ] [project.urls] "Homepage" = "https://creator.poe.com/" [tool.pyright] pythonVersion = "3.9" [tool.pytest.ini_options] addopts = "--cov=src/fastapi_poe --cov-fail-under=80" required_plugins = ["pytest-cov"] [tool.black] target-version = ['py39'] skip-magic-trailing-comma = true [tool.ruff] lint.select = [ "F", "E", "I", # import sorting "ANN", # type annotations for everything "C4", # flake8-comprehensions "B", # bugbear "SIM", # simplify "UP", # pyupgrade "PIE810", # startswith/endswith with a tuple "SIM101", # mergeable isinstance() calls "SIM201", # "not ... == ..." -> "... != ..." "SIM202", # "not ... != ..." -> "... == ..." "C400", # unnecessary list() calls "C401", # unnecessary set() calls "C402", # unnecessary dict() calls "C403", # unnecessary listcomp within set() call "C404", # unnecessary listcomp within dict() call "C405", # use set literals "C406", # use dict literals "C409", # tuple() calls that can be replaced with literals "C410", # list() calls that can be replaced with literals "C411", # list() calls with genexps that can be replaced with listcomps "C413", # unnecessary list() calls around sorted() "C414", # unnecessary list() calls inside sorted() "C417", # unnecessary map() calls that can be replaced with listcomps/genexps "C418", # unnecessary dict() calls that can be replaced with literals "PERF101", # unnecessary list() calls that can be replaced with literals ] 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 ] line-length = 100 target-version = "py39" ================================================ FILE: src/fastapi_poe/__init__.py ================================================ __all__ = [ "PoeBot", "run", "make_app", "stream_request", "get_bot_response", "get_bot_response_sync", "get_final_response", "BotError", "BotErrorNoRetry", "Attachment", "ProtocolMessage", "Sender", "User", "MessageReaction", "QueryRequest", "SettingsRequest", "ReportFeedbackRequest", "ReportReactionRequest", "ReportErrorRequest", "SettingsResponse", "PartialResponse", "ErrorResponse", "MetaResponse", "DataResponse", "AttachmentUploadResponse", "RequestContext", "ToolDefinition", "ToolCallDefinition", "ToolResultDefinition", "MessageFeedback", "sync_bot_settings", "CostItem", "InsufficientFundError", "CostRequestError", "upload_file", "upload_file_sync", "ParameterControls", "Section", "Tab", "FullControls", "ConditionallyRenderControls", "ComparatorCondition", "ParameterValue", "LiteralValue", "BaseControl", "AspectRatio", "AspectRatioOption", "Slider", "ToggleSwitch", "DropDown", "ValueNamePair", "TextArea", "TextField", "Divider", "Number", ] from .base import CostRequestError, InsufficientFundError, PoeBot, make_app, run from .client import ( BotError, BotErrorNoRetry, get_bot_response, get_bot_response_sync, get_final_response, stream_request, sync_bot_settings, upload_file, upload_file_sync, ) from .types import ( AspectRatio, AspectRatioOption, Attachment, AttachmentUploadResponse, BaseControl, ComparatorCondition, ConditionallyRenderControls, CostItem, DataResponse, Divider, DropDown, ErrorResponse, FullControls, LiteralValue, MessageFeedback, MessageReaction, MetaResponse, Number, ParameterControls, ParameterValue, PartialResponse, ProtocolMessage, QueryRequest, ReportErrorRequest, ReportFeedbackRequest, ReportReactionRequest, RequestContext, Section, Sender, SettingsRequest, SettingsResponse, Slider, Tab, TextArea, TextField, ToggleSwitch, ToolCallDefinition, ToolDefinition, ToolResultDefinition, User, ValueNamePair, ) ================================================ FILE: src/fastapi_poe/base.py ================================================ import argparse import asyncio import copy import json import logging import os import random import string import sys import warnings from collections import defaultdict from collections.abc import AsyncIterable, Awaitable, Sequence from dataclasses import dataclass from typing import BinaryIO, Callable, Optional, Union from urllib.parse import unquote, urlparse import httpx import httpx_sse from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse, JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sse_starlette.event import ServerSentEvent from sse_starlette.sse import EventSourceResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import Message from typing_extensions import deprecated, overload from fastapi_poe.client import PROTOCOL_VERSION, sync_bot_settings, upload_file from fastapi_poe.templates import ( IMAGE_VISION_ATTACHMENT_TEMPLATE, TEXT_ATTACHMENT_TEMPLATE, URL_ATTACHMENT_TEMPLATE, ) from fastapi_poe.types import ( Attachment, AttachmentUploadResponse, ContentType, CostItem, DataResponse, ErrorResponse, Identifier, MetaResponse, PartialResponse, ProtocolMessage, QueryRequest, ReportErrorRequest, ReportFeedbackRequest, ReportReactionRequest, RequestContext, Sender, SettingsRequest, SettingsResponse, ) logger = logging.getLogger("uvicorn.default") POE_API_WEBSERVER_BASE_URL = "https://www.quora.com/poe_api/" class InvalidParameterError(Exception): pass class CostRequestError(Exception): pass class InsufficientFundError(Exception): pass class LoggingMiddleware(BaseHTTPMiddleware): # pragma: no cover async def set_body(self, request: Request) -> None: receive_ = await request._receive() async def receive() -> Message: return receive_ request._receive = receive async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: logger.info(f"Request: {request.method} {request.url}") try: # Per https://github.com/tiangolo/fastapi/issues/394#issuecomment-927272627 # to avoid blocking. await self.set_body(request) body = await request.json() logger.debug(f"Request body: {json.dumps(body)}") except json.JSONDecodeError: logger.error("Request body: Unable to parse JSON") response = await call_next(request) logger.info(f"Response status: {response.status_code}") try: if hasattr(response, "body"): body = json.loads(bytes(response.body).decode()) logger.debug(f"Response body: {json.dumps(body)}") except json.JSONDecodeError: logger.error("Response body: Unable to parse JSON") return response async def http_exception_handler(request: Request, ex: Exception) -> Response: logger.error(ex) return Response(status_code=500, content="Internal server error") http_bearer = HTTPBearer() def generate_inline_ref() -> str: return "".join(random.choices(string.ascii_letters + string.digits, k=8)) def get_filename_from_url(url: str) -> str: parsed_url = urlparse(url) filename = os.path.basename(parsed_url.path) filename = unquote(filename) return filename or "downloaded_file" @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` (`str = "/"`): 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` (`Optional[str] = None`): 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. - `should_insert_attachment_messages` (`bool = True`): A flag to decide whether to parse out content from attachments and insert them as messages into the conversation. 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. - `concat_attachments_to_message` (`bool = False`): **DEPRECATED**: Please set `should_insert_attachment_messages` instead. """ path: str = "/" # Path where this bot will be exposed access_key: Optional[str] = None # Access key for this bot bot_name: Optional[str] = None # Name of the bot using this PoeBot instance in Poe should_insert_attachment_messages: bool = ( True # Whether to insert attachment messages into the conversation ) concat_attachments_to_message: bool = False # Deprecated # Override these for your bot async def get_response( self, request: QueryRequest ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]: """ Override this to define your bot's response given a user query. #### Parameters: - `request` (`QueryRequest`): an object representing the chat response request from Poe. This will contain information about the chat state among other things. #### Returns: - `AsyncIterable[PartialResponse]`: objects representing your response to the Poe servers. This is what gets displayed to the user. Example usage: ```python async def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]: last_message = request.query[-1].content yield fp.PartialResponse(text=last_message) ``` """ yield self.text_event("hello") async def get_response_with_context( self, request: QueryRequest, context: RequestContext ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]: """ A version of `get_response` that also includes the request context information. By default, this will call `get_response`. #### Parameters: - `request` (`QueryRequest`): an object representing the chat response request from Poe. This will contain information about the chat state among other things. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: - `AsyncIterable[Union[PartialResponse, ErrorResponse]]`: objects representing your response to the Poe servers. This is what gets displayed to the user. """ try: async for event in self.get_response(request): yield event except InsufficientFundError: yield ErrorResponse(error_type="insufficient_fund", text="") async def get_settings(self, setting: SettingsRequest) -> SettingsResponse: """ Override this to define your bot's settings. #### Parameters: - `setting` (`SettingsRequest`): An object representing the settings request. #### Returns: - `SettingsResponse`: An object representing the settings you want to use for your bot. """ 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`. #### Parameters: - `setting` (`SettingsRequest`): An object representing the settings request. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: - `SettingsResponse`: An object representing the settings you want to use for your bot. """ settings = await self.get_settings(setting) return settings async def on_feedback(self, feedback_request: ReportFeedbackRequest) -> None: """ Override this to record feedback from the user. #### Parameters: - `feedback_request` (`ReportFeedbackRequest`): An object representing the Feedback request from Poe. This is sent out when a user provides feedback on a response on your bot. #### Returns: `None` """ 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`. #### Parameters: - `feedback_request` (`ReportFeedbackRequest`): An object representing a feedback request from Poe. This is sent out when a user provides feedback on a response on your bot. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: `None` """ await self.on_feedback(feedback_request) async def on_reaction_with_context( self, reaction_request: ReportReactionRequest, context: RequestContext ) -> None: """ Override this to record a reaction from the user. This also includes the request context. #### Parameters: - `reaction_request` (`ReportReactionRequest`): An object representing a reaction request from Poe. This is sent out when a user provides reaction on a response on your bot. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: `None` """ pass async def on_error(self, error_request: ReportErrorRequest) -> None: """ Override this to record errors from the Poe server. #### Parameters: - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe. This is sent out when the Poe server runs into an issue processing the response from your bot. #### Returns: `None` """ logger.error(f"Error from Poe server: {error_request}") async def on_error_with_context( self, error_request: ReportErrorRequest, context: RequestContext ) -> None: """ A version of `on_error` that also includes the request context information. By default, this will call `on_error`. #### Parameters: - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe. This is sent out when the Poe server runs into an issue processing the response from your bot. - `context` (`RequestContext`): an object representing the current HTTP request. #### Returns: `None` """ await self.on_error(error_request) # Helpers for generating responses def __post_init__(self) -> None: self._file_events_to_yield: dict[Identifier, list[ServerSentEvent]] = {} # This overload leaves access_key as the first argument, but is deprecated. @overload @deprecated( "The access_key and content_type parameters are deprecated. " "Set the access_key when creating the Bot object instead." ) async def post_message_attachment( self, access_key: str, message_id: Identifier, *, download_url: Optional[str] = None, download_filename: Optional[str] = None, file_data: Optional[Union[bytes, BinaryIO]] = None, filename: Optional[str] = None, content_type: Optional[str] = None, is_inline: bool = False, base_url: str = POE_API_WEBSERVER_BASE_URL, ) -> AttachmentUploadResponse: ... # This overload requires all parameters to be passed as keywords @overload async def post_message_attachment( self, *, message_id: Identifier, download_url: Optional[str] = None, download_filename: Optional[str] = None, file_data: Optional[Union[bytes, BinaryIO]] = None, filename: Optional[str] = None, is_inline: bool = False, base_url: str = POE_API_WEBSERVER_BASE_URL, ) -> AttachmentUploadResponse: ... async def post_message_attachment( self, access_key: Optional[str] = None, message_id: Optional[Identifier] = None, *, download_url: Optional[str] = None, download_filename: Optional[str] = None, file_data: Optional[Union[bytes, BinaryIO]] = None, filename: Optional[str] = None, content_type: Optional[str] = None, is_inline: bool = False, base_url: str = POE_API_WEBSERVER_BASE_URL, ) -> AttachmentUploadResponse: """ Used to output an attachment in your bot's response. #### Parameters: - `message_id` (`Identifier`): The message id associated with the current QueryRequest. - `download_url` (`Optional[str] = None`): A url to the file to be attached to the message. - `download_filename` (`Optional[str] = None`): A filename to be used when storing the downloaded attachment. If not set, the filename from the `download_url` is used. - `file_data` (`Optional[Union[bytes, BinaryIO]] = None`): The contents of the file to be uploaded. This should be a bytes-like or file object. - `filename` (`Optional[str] = None`): The name of the file to be attached. - `access_key` (`str`): **DEPRECATED**: Please set the access_key when creating the Bot object instead. #### Returns: - `AttachmentUploadResponse` **Note**: You need to provide either the `download_url` or both of `file_data` and `filename`. """ assert message_id is not None, "message_id parameter is required" name = filename or download_filename if not name: if not download_url: raise InvalidParameterError( "filename or download_url/download_filename required" ) else: name = get_filename_from_url(download_url) if self.access_key: if access_key: warnings.warn( "Bot already has an access key, access_key parameter is not needed.", DeprecationWarning, stacklevel=2, ) attachment_access_key = access_key else: attachment_access_key = self.access_key else: if access_key is None: raise InvalidParameterError( "access_key parameter is required if bot is not" + " provided with an access_key when make_app is called." ) attachment_access_key = access_key if content_type is not None: warnings.warn( "content_type parameter is deprecated, and will be removed in a future release.", DeprecationWarning, stacklevel=2, ) attachment = await self._upload_file( file=file_data, file_url=download_url, file_name=filename or download_filename, api_key=attachment_access_key, base_url=base_url, ) inline_ref = generate_inline_ref() if is_inline else None file_events_to_yield = self._file_events_to_yield.setdefault(message_id, []) assert name is not None # we check this above, but pyright can't detect it file_events_to_yield.append( self.file_event( url=attachment.url, content_type=attachment.content_type, name=name, inline_ref=inline_ref, ) ) return AttachmentUploadResponse( attachment_url=attachment.url, mime_type=attachment.content_type, inline_ref=inline_ref, ) async def _upload_file( self, *, file: Optional[Union[bytes, BinaryIO]], file_url: Optional[str], file_name: Optional[str], api_key: str, base_url: str, ) -> Attachment: return await upload_file( file=file, file_url=file_url, file_name=file_name, api_key=api_key, base_url=base_url, ) @deprecated( "This method is deprecated. Use `insert_attachment_messages` instead." "This method will be removed in a future release." ) def concat_attachment_content_to_message_body( self, query_request: QueryRequest ) -> QueryRequest: # pragma: no cover """ **DEPRECATED**: This method is deprecated. Use `insert_attachment_messages` instead. 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. #### Parameters: - `query_request` (`QueryRequest`): the request object from Poe. #### Returns: - `QueryRequest`: the request object after the attachments are unpacked and added to the message body. """ last_message = query_request.query[-1] concatenated_content = last_message.content for attachment in last_message.attachments: if attachment.parsed_content: if attachment.content_type == "text/html": url_attachment_content = URL_ATTACHMENT_TEMPLATE.format( attachment_name=attachment.name, content=attachment.parsed_content, ) concatenated_content = ( f"{concatenated_content}\n\n{url_attachment_content}" ) elif "text" in attachment.content_type: text_attachment_content = TEXT_ATTACHMENT_TEMPLATE.format( attachment_name=attachment.name, attachment_parsed_content=attachment.parsed_content, ) concatenated_content = ( f"{concatenated_content}\n\n{text_attachment_content}" ) elif "image" in attachment.content_type: parsed_content_filename = attachment.parsed_content.split("***")[0] parsed_content_text = attachment.parsed_content.split("***")[1] image_attachment_content = IMAGE_VISION_ATTACHMENT_TEMPLATE.format( filename=parsed_content_filename, parsed_image_description=parsed_content_text, ) concatenated_content = ( f"{concatenated_content}\n\n{image_attachment_content}" ) modified_last_message = last_message.model_copy( update={"content": concatenated_content} ) modified_query = query_request.model_copy( update={"query": query_request.query[:-1] + [modified_last_message]} ) return modified_query def insert_attachment_messages(self, query_request: QueryRequest) -> QueryRequest: """ Insert messages containing the contents of each user attachment right before the last user message. This ensures the bot can consider all relevant information when generating a response. This will be called by default if `should_insert_attachment_messages` is set to `True` but can also be used manually if needed. #### Parameters: - `query_request` (`QueryRequest`): the request object from Poe. #### Returns: - `QueryRequest`: the request object after the attachments are unpacked and added to the message body. """ last_message = query_request.query[-1] text_attachment_messages = [] image_attachment_messages = [] for attachment in last_message.attachments: if attachment.parsed_content: if attachment.content_type == "text/html": url_attachment_content = URL_ATTACHMENT_TEMPLATE.format( attachment_name=attachment.name, content=attachment.parsed_content, ) text_attachment_messages.append( ProtocolMessage( role="user", sender=Sender(), content=url_attachment_content ) ) elif ( attachment.content_type.startswith("text/") or attachment.content_type == "application/pdf" ): text_attachment_content = TEXT_ATTACHMENT_TEMPLATE.format( attachment_name=attachment.name, attachment_parsed_content=attachment.parsed_content, ) text_attachment_messages.append( ProtocolMessage( role="user", sender=Sender(), content=text_attachment_content, ) ) elif "image" in attachment.content_type: try: # Poe currently sends analysis in the format of filename***analysis parsed_content_filename, parsed_content_text = ( attachment.parsed_content.split("***", 1) ) except ValueError: # If the format is not filename***analysis, use the attachment filename parsed_content_filename = attachment.name parsed_content_text = attachment.parsed_content image_attachment_content = IMAGE_VISION_ATTACHMENT_TEMPLATE.format( filename=parsed_content_filename, parsed_image_description=parsed_content_text, ) image_attachment_messages.append( ProtocolMessage( role="user", sender=Sender(), content=image_attachment_content, ) ) modified_query = query_request.model_copy( update={ "query": query_request.query[:-1] + text_attachment_messages + image_attachment_messages + [last_message] } ) return modified_query def make_prompt_author_role_alternated( self, protocol_messages: Sequence[ProtocolMessage] ) -> Sequence[ProtocolMessage]: """ Concatenate consecutive messages from the same author into a single message. This is useful for LLMs that require role alternation between user and bot messages. #### Parameters: - `protocol_messages` (`Sequence[ProtocolMessage]`): the messages to make alternated. #### Returns: - `Sequence[ProtocolMessage]`: the modified messages. """ new_messages = [] for protocol_message in protocol_messages: if new_messages and protocol_message.role == new_messages[-1].role: prev_message = new_messages.pop() new_content = prev_message.content + "\n\n" + protocol_message.content new_attachments = [] added_attachment_urls = set() for attachment in ( protocol_message.attachments + prev_message.attachments ): if attachment.url not in added_attachment_urls: added_attachment_urls.add(attachment.url) new_attachments.append(attachment) new_messages.append( prev_message.model_copy( update={"content": new_content, "attachments": new_attachments} ) ) else: new_messages.append(protocol_message) return new_messages async def capture_cost( self, request: QueryRequest, amounts: Union[list[CostItem], CostItem], base_url: str = "https://api.poe.com/", ) -> None: """ Used to capture variable costs for monetized and eligible bot creators. Visit https://creator.poe.com/docs/creator-monetization for more information. #### Parameters: - `request` (`QueryRequest`): The currently handled QueryRequest object. - `amounts` (`Union[list[CostItem], CostItem]`): The to be captured amounts. #### Returns: `None` """ if not self.access_key: raise CostRequestError( "Please provide the bot access_key when make_app is called." ) if not request.bot_query_id: raise InvalidParameterError( "bot_query_id is required to make cost requests." ) url = f"{base_url}bot/cost/{request.bot_query_id}/capture" result = await self._cost_requests_inner( amounts=amounts, access_key=self.access_key, url=url ) if not result: raise InsufficientFundError() async def authorize_cost( self, request: QueryRequest, amounts: Union[list[CostItem], CostItem], base_url: str = "https://api.poe.com/", ) -> None: """ Used to authorize a cost for monetized and eligible bot creators. Visit https://creator.poe.com/docs/creator-monetization for more information. #### Parameters: - `request` (`QueryRequest`): The currently handled QueryRequest object. - `amounts` (`Union[list[CostItem], CostItem]`): The to be authorized amounts. #### Returns: `None` """ if not self.access_key: raise CostRequestError( "Please provide the bot access_key when make_app is called." ) if not request.bot_query_id: raise InvalidParameterError( "bot_query_id is required to make cost requests." ) url = f"{base_url}bot/cost/{request.bot_query_id}/authorize" result = await self._cost_requests_inner( amounts=amounts, access_key=self.access_key, url=url ) if not result: raise InsufficientFundError() async def _cost_requests_inner( self, amounts: Union[list[CostItem], CostItem], access_key: str, url: str ) -> bool: amounts = [amounts] if isinstance(amounts, CostItem) else amounts amounts_dicts = [amount.model_dump() for amount in amounts] data = {"amounts": amounts_dicts, "access_key": access_key} try: async with ( httpx.AsyncClient(timeout=300) as client, httpx_sse.aconnect_sse( client, method="POST", url=url, json=data ) as event_source, ): if event_source.response.status_code != 200: error_pieces = [ json.loads(event.data).get("message", "") async for event in event_source.aiter_sse() ] raise CostRequestError( f"{event_source.response.status_code} " f"{event_source.response.reason_phrase}: {''.join(error_pieces)}" ) async for event in event_source.aiter_sse(): if event.event == "result": event_data = json.loads(event.data) result = event_data["status"] return result == "success" return False except httpx.HTTPError: logger.error( "An HTTP error occurred when attempting to send a cost request." ) raise @staticmethod def text_event(text: str) -> ServerSentEvent: return ServerSentEvent(data=json.dumps({"text": text}), event="text") @staticmethod def file_event( url: str, content_type: str, name: str, inline_ref: Optional[str] = None ) -> ServerSentEvent: return ServerSentEvent( data=json.dumps( { "url": url, "content_type": content_type, "name": name, "inline_ref": inline_ref, } ), event="file", ) @staticmethod def data_event(metadata: str) -> ServerSentEvent: return ServerSentEvent(data=json.dumps({"metadata": metadata}), event="data") @staticmethod def replace_response_event(text: str) -> ServerSentEvent: return ServerSentEvent( data=json.dumps({"text": text}), event="replace_response" ) @staticmethod def done_event() -> ServerSentEvent: return ServerSentEvent(data="{}", event="done") @staticmethod def suggested_reply_event(text: str) -> ServerSentEvent: return ServerSentEvent(data=json.dumps({"text": text}), event="suggested_reply") @staticmethod def meta_event( *, content_type: ContentType = "text/markdown", refetch_settings: bool = False, linkify: bool = True, suggested_replies: bool = False, ) -> ServerSentEvent: return ServerSentEvent( data=json.dumps( { "content_type": content_type, "refetch_settings": refetch_settings, "linkify": linkify, "suggested_replies": suggested_replies, } ), event="meta", ) @staticmethod def error_event( text: Optional[str] = None, *, raw_response: Optional[object] = None, allow_retry: bool = True, error_type: Optional[str] = None, ) -> ServerSentEvent: data: dict[str, Union[bool, str]] = {"allow_retry": allow_retry} if text is not None: data["text"] = text if raw_response is not None: data["raw_response"] = repr(raw_response) if error_type is not None: data["error_type"] = error_type return ServerSentEvent(data=json.dumps(data), event="error") # Internal handlers async def handle_report_feedback( self, feedback_request: ReportFeedbackRequest, context: RequestContext ) -> JSONResponse: await self.on_feedback_with_context(feedback_request, context) return JSONResponse({}) async def handle_report_reaction( self, reaction_request: ReportReactionRequest, context: RequestContext ) -> JSONResponse: await self.on_reaction_with_context(reaction_request, context) return JSONResponse({}) async def handle_report_error( self, error_request: ReportErrorRequest, context: RequestContext ) -> JSONResponse: await self.on_error_with_context(error_request, context) return JSONResponse({}) async def handle_settings( self, settings_request: SettingsRequest, context: RequestContext ) -> JSONResponse: settings = await self.get_settings_with_context(settings_request, context) return JSONResponse(settings.dict()) async def _yield_pending_file_events( self, message_id: Identifier ) -> AsyncIterable[ServerSentEvent]: file_events_to_yield = self._file_events_to_yield.pop(message_id, []) for fe in file_events_to_yield: yield fe async def handle_query( self, request: QueryRequest, context: RequestContext ) -> AsyncIterable[ServerSentEvent]: try: if self.should_insert_attachment_messages: request = self.insert_attachment_messages(query_request=request) elif self.concat_attachments_to_message: warnings.warn( "concat_attachments_to_message is deprecated. " "Use should_insert_attachment_messages instead.", DeprecationWarning, stacklevel=2, ) request = self.concat_attachment_content_to_message_body( query_request=request ) async for event in self.get_response_with_context(request, context): # yield any pending file events from post_message_attachment first. # this is to ensure responses with inline_ref are sent after attachment is made. async for pending_file_event in self._yield_pending_file_events( request.message_id ): yield pending_file_event if isinstance(event, PartialResponse) and event.attachment: attachment = event.attachment yield self.file_event( url=attachment.url, content_type=attachment.content_type, name=attachment.name, inline_ref=attachment.inline_ref, ) if isinstance(event, ServerSentEvent): yield event elif isinstance(event, ErrorResponse): yield self.error_event( event.text, raw_response=event.raw_response, allow_retry=event.allow_retry, error_type=event.error_type, ) elif isinstance(event, MetaResponse): yield self.meta_event( content_type=event.content_type, refetch_settings=event.refetch_settings, linkify=event.linkify, suggested_replies=event.suggested_replies, ) elif isinstance(event, DataResponse): yield self.data_event(event.metadata) elif event.is_suggested_reply: yield self.suggested_reply_event(event.text) elif event.is_replace_response: yield self.replace_response_event(event.text) else: yield self.text_event(event.text) # yield any remaining file events async for pending_file_event in self._yield_pending_file_events( request.message_id ): yield pending_file_event except Exception as e: logger.exception("Error responding to query") yield self.error_event( "The bot encountered an unexpected issue.", raw_response=e, allow_retry=False, ) yield self.done_event() def _find_access_key(*, access_key: str, api_key: str) -> Optional[str]: """Figures out the access key. The order of preference is: 1) access_key= 2) $POE_ACCESS_KEY 3) api_key= 4) $POE_API_KEY """ if access_key: return access_key environ_poe_access_key = os.environ.get("POE_ACCESS_KEY") if environ_poe_access_key: return environ_poe_access_key if api_key: warnings.warn( "usage of api_key is deprecated, pass your key using access_key instead", DeprecationWarning, stacklevel=3, ) return api_key environ_poe_api_key = os.environ.get("POE_API_KEY") if environ_poe_api_key: warnings.warn( "usage of POE_API_KEY is deprecated, pass your key using POE_ACCESS_KEY instead", DeprecationWarning, stacklevel=3, ) return environ_poe_api_key return None def _verify_access_key( *, access_key: str, api_key: str, allow_without_key: bool = False ) -> Optional[str]: """Checks whether we have a valid access key and returns it.""" _access_key = _find_access_key(access_key=access_key, api_key=api_key) if not _access_key: if allow_without_key: return None print( "Please provide an access key.\n" "You can get a key from the create_bot page at: https://poe.com/create_bot?server=1\n" "You can then pass the key using the access_key param to the run() or make_app() " "functions, or by using the POE_ACCESS_KEY environment variable." ) sys.exit(1) if len(_access_key) != 32: print("Invalid access key (should be 32 characters)") sys.exit(1) return _access_key def _add_routes_for_bot(app: FastAPI, bot: PoeBot) -> None: async def index() -> Response: url = "https://poe.com/create_bot?server=1" return HTMLResponse( "

FastAPI Poe bot server

Congratulations! Your server" " is running. To connect it to Poe, create a bot at {url}.

' ) def auth_user( authorization: HTTPAuthorizationCredentials = Depends(http_bearer), ) -> None: if bot.access_key is None: return if ( authorization.scheme != "Bearer" or authorization.credentials != bot.access_key ): raise HTTPException( status_code=401, detail="Invalid access key", headers={"WWW-Authenticate": "Bearer"}, ) async def poe_post(request: Request, dict: object = Depends(auth_user)) -> Response: request_body = await request.json() request_body["http_request"] = request if request_body["type"] == "query": return EventSourceResponse( bot.handle_query( QueryRequest.parse_obj( { **request_body, "access_key": bot.access_key or "", "api_key": bot.access_key or "", } ), RequestContext(http_request=request), ) ) elif request_body["type"] == "settings": return await bot.handle_settings( SettingsRequest.parse_obj(request_body), RequestContext(http_request=request), ) elif request_body["type"] == "report_feedback": return await bot.handle_report_feedback( ReportFeedbackRequest.parse_obj(request_body), RequestContext(http_request=request), ) elif request_body["type"] == "report_reaction": return await bot.handle_report_reaction( ReportReactionRequest.parse_obj(request_body), RequestContext(http_request=request), ) elif request_body["type"] == "report_error": return await bot.handle_report_error( ReportErrorRequest.parse_obj(request_body), RequestContext(http_request=request), ) else: raise HTTPException(status_code=501, detail="Unsupported request type") app.get(bot.path)(index) app.post(bot.path)(poe_post) def make_app( bot: Union[PoeBot, Sequence[PoeBot]], access_key: str = "", *, bot_name: str = "", api_key: str = "", allow_without_key: bool = False, app: Optional[FastAPI] = None, ) -> FastAPI: """ Create an app object for your bot(s). #### Parameters: - `bot` (`Union[PoeBot, Sequence[PoeBot]]`): A bot object or a list of bot objects if you want to host multiple bots on one server. - `access_key` (`str = ""`): 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. - `bot_name` (`str = ""`): The name of the bot as it appears on poe.com. - `api_key` (`str = ""`): **DEPRECATED**: Please set the access_key when creating the Bot object instead. - `allow_without_key` (`bool = False`): 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` (`Optional[FastAPI] = None`): 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. #### Returns: - `FastAPI`: A FastAPI app configured to serve your bot when run. """ if app is None: app = FastAPI() app.add_exception_handler(RequestValidationError, http_exception_handler) if isinstance(bot, PoeBot): if bot.access_key is None: bot.access_key = _verify_access_key( access_key=access_key, api_key=api_key, allow_without_key=allow_without_key, ) elif access_key: raise ValueError( "Cannot provide access_key if the bot object already has an access key" ) elif api_key: raise ValueError( "Cannot provide api_key if the bot object already has an access key" ) if bot.bot_name is None: bot.bot_name = bot_name elif bot_name: raise ValueError( "Cannot provide bot_name if the bot object already has a bot_name" ) bots = [bot] else: if access_key or api_key or bot_name: raise ValueError( "When serving multiple bots, the access_key/bot_name must be set on each bot" ) bots = bot # Ensure paths are unique path_to_bots = defaultdict(list) for bot in bots: path_to_bots[bot.path].append(bot) for path, bots_of_path in path_to_bots.items(): if len(bots_of_path) > 1: raise ValueError( f"Multiple bots are trying to use the same path: {path}: {bots_of_path}. " "Please use a different path for each bot." ) for bot_obj in bots: if bot_obj.access_key is None and not allow_without_key: raise ValueError(f"Missing access key on {bot_obj}") _add_routes_for_bot(app, bot_obj) if not bot_obj.bot_name or not bot_obj.access_key: logger.warning("\n************* Warning *************") logger.warning( "Bot name or access key is not set for PoeBot.\n" "Bot settings will NOT be synced automatically on server start/update." "Please remember to sync bot settings manually.\n\n" "For more information, see: https://creator.poe.com/docs/server-bots/updating-bot-settings" ) logger.warning("\n************* Warning *************") else: try: settings_response = asyncio.run( bot_obj.get_settings( SettingsRequest(version=PROTOCOL_VERSION, type="settings") ) ) sync_bot_settings( bot_name=bot_obj.bot_name, settings=settings_response.model_dump(), access_key=bot_obj.access_key, ) except Exception as e: logger.error("\n*********** Error ***********") logger.error( f"Bot settings sync failed for {bot_obj.bot_name}: \n{e}\n\n" ) logger.error("Please sync bot settings manually.\n\n") logger.error( "For more information, see: https://creator.poe.com/docs/server-bots/updating-bot-settings" ) logger.error("\n*********** Error ***********") # Uncomment this line to print out request and response # app.add_middleware(LoggingMiddleware) return app def run( bot: Union[PoeBot, Sequence[PoeBot]], access_key: str = "", *, api_key: str = "", allow_without_key: bool = False, app: Optional[FastAPI] = None, ) -> None: """ Serve a poe bot using a FastAPI app. This function should be used when you are running the bot locally. The parameters are the same as they are for `make_app`. #### Returns: `None` """ app = make_app( bot, access_key=access_key, api_key=api_key, allow_without_key=allow_without_key, app=app, ) parser = argparse.ArgumentParser("FastAPI sample Poe bot server") parser.add_argument("-p", "--port", type=int, default=8080) args = parser.parse_args() port = args.port logger.info("Starting") import uvicorn.config log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) log_config["formatters"]["default"][ "fmt" ] = "%(asctime)s - %(levelname)s - %(message)s" uvicorn.run(app, host="0.0.0.0", port=port, log_config=log_config) ================================================ FILE: src/fastapi_poe/client.py ================================================ """ Client for talking to other Poe bots through the Poe bot query API. For more details, see: https://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe """ import asyncio import contextlib import inspect import io import json import os import warnings from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass, field from typing import Any, BinaryIO, Callable, Optional, Union, cast import httpx import httpx_sse from fastapi_poe.sync_utils import run_sync from .types import ( Attachment, ContentType, FunctionCallDefinition, Identifier, ProtocolMessage, QueryRequest, SettingsResponse, ToolCallDefinition, ToolCallDefinitionDelta, ToolDefinition, ToolResultDefinition, ) from .types import MetaResponse as MetaMessage from .types import PartialResponse as BotMessage PROTOCOL_VERSION = "1.2" MESSAGE_LENGTH_LIMIT = 10_000 IDENTIFIER_LENGTH = 32 MAX_EVENT_COUNT = 1000 ErrorHandler = Callable[[Exception, str], None] class AttachmentUploadError(Exception): """Raised when there is an error uploading an attachment.""" class BotError(Exception): """Raised when there is an error communicating with the bot.""" class BotErrorNoRetry(BotError): """Subclass of BotError raised when we're not allowed to retry.""" class InvalidBotSettings(Exception): """Raised when a bot returns invalid settings.""" def _safe_ellipsis(obj: object, limit: int) -> str: if not isinstance(obj, str): obj = repr(obj) if len(obj) > limit: obj = obj[: limit - 3] + "..." return obj @dataclass class _BotContext: endpoint: str session: httpx.AsyncClient = field(repr=False) api_key: Optional[str] = field(default=None, repr=False) on_error: Optional[ErrorHandler] = field(default=None, repr=False) extra_headers: Optional[dict[str, str]] = field(default=None, repr=False) @property def headers(self) -> dict[str, str]: headers = {"Accept": "application/json"} if self.api_key is not None: headers["Authorization"] = f"Bearer {self.api_key}" if self.extra_headers is not None: headers.update(self.extra_headers) return headers async def report_error( self, message: str, metadata: Optional[dict[str, Any]] = None ) -> None: """Report an error to the bot server.""" if self.on_error is not None: long_message = ( f"Protocol bot error: {message} with metadata {metadata} " f"for endpoint {self.endpoint}" ) self.on_error(BotError(message), long_message) await self.session.post( self.endpoint, headers=self.headers, json={ "version": PROTOCOL_VERSION, "type": "report_error", "message": message, "metadata": metadata or {}, }, ) async def report_feedback( self, message_id: Identifier, user_id: Identifier, conversation_id: Identifier, feedback_type: str, ) -> None: """Report message feedback to the bot server.""" await self.session.post( self.endpoint, headers=self.headers, json={ "version": PROTOCOL_VERSION, "type": "report_feedback", "message_id": message_id, "user_id": user_id, "conversation_id": conversation_id, "feedback_type": feedback_type, }, ) async def report_reaction( self, message_id: Identifier, user_id: Identifier, conversation_id: Identifier, reaction: str, ) -> None: """Report message reaction to the bot server.""" await self.session.post( self.endpoint, headers=self.headers, json={ "version": PROTOCOL_VERSION, "type": "report_reaction", "message_id": message_id, "user_id": user_id, "conversation_id": conversation_id, "reaction": reaction, }, ) async def fetch_settings(self) -> SettingsResponse: """Fetches settings from a Poe server bot endpoint.""" resp = await self.session.post( self.endpoint, headers=self.headers, json={"version": PROTOCOL_VERSION, "type": "settings"}, ) return resp.json() async def perform_query_request( self, *, request: QueryRequest, tools: Optional[list[ToolDefinition]], tool_calls: Optional[list[ToolCallDefinition]], tool_results: Optional[list[ToolResultDefinition]], ) -> AsyncGenerator[BotMessage, None]: chunks: list[str] = [] message_id = request.message_id event_count = 0 error_reported = False payload = request.model_dump() if tools is not None: payload["tools"] = [tool.model_dump() for tool in tools] if tool_calls is not None: payload["tool_calls"] = [tool_call.model_dump() for tool_call in tool_calls] if tool_results is not None: payload["tool_results"] = [ tool_result.model_dump() for tool_result in tool_results ] async with httpx_sse.aconnect_sse( self.session, "POST", self.endpoint, headers=self.headers, json=payload ) as event_source: async for event in event_source.aiter_sse(): event_count += 1 index: Optional[int] = await self._get_single_json_integer_field_safe( event.data, event.event, message_id, "index" ) if event.event == "done": # Don't send a report if we already told the bot about some other mistake. if not chunks and not error_reported and not tools: await self.report_error( "Bot returned no text in response", {"message_id": message_id}, ) return elif event.event == "text": text = await self._get_single_json_field( event.data, "text", message_id ) elif event.event == "replace_response": text = await self._get_single_json_field( event.data, "replace_response", message_id ) chunks.clear() elif event.event == "file": yield BotMessage( text="", attachment=Attachment( url=await self._get_single_json_field( event.data, "file", message_id, "url" ), content_type=await self._get_single_json_field( event.data, "file", message_id, "content_type" ), name=await self._get_single_json_field( event.data, "file", message_id, "name" ), inline_ref=await self._get_single_json_string_field_safe( event.data, "file", message_id, "inline_ref" ), ), index=index, ) continue elif event.event == "suggested_reply": text = await self._get_single_json_field( event.data, "suggested_reply", message_id ) yield BotMessage( text=text, raw_response={"type": event.event, "text": event.data}, full_prompt=repr(request), is_suggested_reply=True, index=index, ) continue elif event.event == "json": yield BotMessage( text="", data=json.loads(event.data), full_prompt=repr(request), index=index, ) continue elif event.event == "meta": if event_count != 1: # spec says a meta event that is not the first event is ignored continue data = await self._load_json_dict(event.data, "meta", message_id) linkify = data.get("linkify", False) if not isinstance(linkify, bool): await self.report_error( "Invalid linkify value in 'meta' event", {"message_id": message_id, "linkify": linkify}, ) error_reported = True continue send_suggested_replies = data.get("suggested_replies", False) if not isinstance(send_suggested_replies, bool): await self.report_error( "Invalid suggested_replies value in 'meta' event", { "message_id": message_id, "suggested_replies": send_suggested_replies, }, ) error_reported = True continue content_type = data.get("content_type", "text/markdown") if not isinstance(content_type, str): await self.report_error( "Invalid content_type value in 'meta' event", {"message_id": message_id, "content_type": content_type}, ) error_reported = True continue yield MetaMessage( text="", raw_response=data, full_prompt=repr(request), linkify=linkify, suggested_replies=send_suggested_replies, content_type=cast(ContentType, content_type), ) continue elif event.event == "error": data = await self._load_json_dict(event.data, "error", message_id) if data.get("allow_retry", True): raise BotError(event.data) else: raise BotErrorNoRetry(event.data) elif event.event == "ping": # Not formally part of the spec, but FastAPI sends this; let's ignore it # instead of sending error reports. continue else: # Truncate the type and message in case it's huge. await self.report_error( f"Unknown event type: {_safe_ellipsis(event.event, 100)}", { "event_data": _safe_ellipsis(event.data, 500), "message_id": message_id, }, ) error_reported = True continue chunks.append(text) yield BotMessage( text=text, raw_response={"type": event.event, "text": event.data}, full_prompt=repr(request), is_replace_response=(event.event == "replace_response"), index=index, ) await self.report_error( "Bot exited without sending 'done' event", {"message_id": message_id} ) async def _get_single_json_field( self, data: str, context: str, message_id: Identifier, field: str = "text" ) -> str: data_dict = await self._load_json_dict(data, context, message_id) text = data_dict[field] if not isinstance(text, str): await self.report_error( f"Expected string in '{field}' field for '{context}' event", {"data": data_dict, "message_id": message_id}, ) raise BotErrorNoRetry(f"Expected string in '{context}' event") return text async def _get_single_json_string_field_safe( self, data: str, context: str, message_id: Identifier, field: str ) -> Optional[str]: data_dict = await self._load_json_dict(data, context, message_id) if field not in data_dict: return None result = data_dict[field] if not isinstance(result, str): return None return result async def _get_single_json_integer_field_safe( self, data: str, context: str, message_id: Identifier, field: str ) -> Optional[int]: data_dict = await self._load_json_dict(data, context, message_id) if field not in data_dict: return None result = data_dict[field] if not isinstance(result, int): return None return result async def _load_json_dict( self, data: str, context: str, message_id: Identifier ) -> dict[str, object]: try: parsed = json.loads(data) except json.JSONDecodeError: await self.report_error( f"Invalid JSON in {context!r} event", {"data": data, "message_id": message_id}, ) # If they are returning invalid JSON, retrying immediately probably won't help raise BotErrorNoRetry(f"Invalid JSON in {context!r} event") from None if not isinstance(parsed, dict): await self.report_error( f"Expected JSON dict in {context!r} event", {"data": data, "message_id": message_id}, ) raise BotError(f"Expected JSON dict in {context!r} event") return cast(dict[str, object], parsed) def _default_error_handler(e: Exception, msg: str) -> None: print("Error in Poe bot:", msg, "\n", repr(e)) async def stream_request( request: QueryRequest, bot_name: str, api_key: str = "", *, tools: Optional[list[ToolDefinition]] = None, tool_executables: Optional[list[Callable]] = None, access_key: str = "", access_key_deprecation_warning_stacklevel: int = 2, session: Optional[httpx.AsyncClient] = None, on_error: ErrorHandler = _default_error_handler, num_tries: int = 2, retry_sleep_time: float = 0.5, base_url: str = "https://api.poe.com/bot/", extra_headers: Optional[dict[str, str]] = None, ) -> 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/server-bots-functional-guides#accessing-other-bots-on-poe #### Parameters: - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object also includes information needed to identify the user for compute point usage. - `bot_name` (`str`): The bot you want to invoke. - `api_key` (`str = ""`): 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`. - tools: (`Optional[list[ToolDefinition]] = None`): A list of ToolDefinition objects describing the functions you have. This is used for OpenAI function calling. - tool_executables: (`Optional[list[Callable]] = None`): A list of functions corresponding to the ToolDefinitions. This is used for OpenAI function calling. When this is set, the LLM-suggested tools will automatically run once, before passing the results back to the LLM for a final response. """ if tools is not None: async for message in _stream_request_with_tools( request=request, bot_name=bot_name, api_key=api_key, tools=tools, tool_executables=tool_executables, access_key=access_key, access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel, session=session, on_error=on_error, num_tries=num_tries, retry_sleep_time=retry_sleep_time, base_url=base_url, extra_headers=extra_headers, ): yield message else: async for message in stream_request_base( request=request, bot_name=bot_name, api_key=api_key, access_key=access_key, access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel, session=session, on_error=on_error, num_tries=num_tries, retry_sleep_time=retry_sleep_time, base_url=base_url, extra_headers=extra_headers, ): yield message async def _get_tool_results( tool_executables: list[Callable], tool_calls: list[ToolCallDefinition] ) -> list[ToolResultDefinition]: tool_executables_dict = { executable.__name__: executable for executable in tool_executables } tool_results = [] for tool_call in tool_calls: tool_call_id = tool_call.id name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) _func = tool_executables_dict[name] if inspect.iscoroutinefunction(_func): content = await _func(**arguments) else: content = _func(**arguments) tool_results.append( ToolResultDefinition( role="tool", tool_call_id=tool_call_id, name=name, content=json.dumps(content), ) ) return tool_results async def _stream_request_with_tools( request: QueryRequest, bot_name: str, api_key: str = "", *, tools: list[ToolDefinition], tool_executables: Optional[list[Callable]] = None, access_key: str = "", access_key_deprecation_warning_stacklevel: int = 2, session: Optional[httpx.AsyncClient] = None, on_error: ErrorHandler = _default_error_handler, num_tries: int = 2, retry_sleep_time: float = 0.5, base_url: str = "https://api.poe.com/bot/", extra_headers: Optional[dict[str, str]] = None, ) -> AsyncGenerator[BotMessage, None]: aggregated_tool_calls: dict[int, ToolCallDefinition] = {} async for message in stream_request_base( request=request, bot_name=bot_name, api_key=api_key, tools=tools, access_key=access_key, access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel, session=session, on_error=on_error, num_tries=num_tries, retry_sleep_time=retry_sleep_time, base_url=base_url, extra_headers=extra_headers, ): if ( message.data is None or "choices" not in message.data or not message.data["choices"] ): yield message continue # If there is a finish reason, skip the chunk. This should be the same as breaking out of # the loop for most models, but we continue to cover situations where other kinds of # chunks might stream in after the finish chunk. finish_reason = message.data["choices"][0]["finish_reason"] if finish_reason is not None: continue if "tool_calls" in message.data["choices"][0]["delta"]: tool_call_deltas: list[ToolCallDefinitionDelta] = [ ToolCallDefinitionDelta(**tool_call_object) for tool_call_object in message.data["choices"][0]["delta"][ "tool_calls" ] ] # If tool_executables is not set, return the tool calls without executing them, # allowing the caller to manage the tool call loop. if tool_executables is None: yield BotMessage( text="", tool_calls=tool_call_deltas, index=message.index ) continue for tool_call_delta in tool_call_deltas: if tool_call_delta.index not in aggregated_tool_calls: # The first chunk of a given index must contain id, type, and function.name. # If this first chunk is missing, the tool call for that index cannot be # aggregated. if ( tool_call_delta.id is None or tool_call_delta.type is None or tool_call_delta.function.name is None ): continue aggregated_tool_calls[tool_call_delta.index] = ToolCallDefinition( id=tool_call_delta.id, type=tool_call_delta.type, function=FunctionCallDefinition( name=tool_call_delta.function.name, arguments=tool_call_delta.function.arguments, ), ) else: aggregated_tool_calls[ tool_call_delta.index ].function.arguments += tool_call_delta.function.arguments # if no tool calls are selected, the deltas contain content instead of tool_calls elif "content" in message.data["choices"][0]["delta"]: yield BotMessage( text=message.data["choices"][0]["delta"]["content"], index=message.index ) # If tool_executables is not set, exit early since there are no functions to execute. if not tool_executables: return tool_calls: list[ToolCallDefinition] = list(aggregated_tool_calls.values()) tool_results = await _get_tool_results(tool_executables, tool_calls) # If we have tool calls and tool results, we still need to get the final response from the # LLM. if tool_calls and tool_results: async for message in stream_request_base( request=request, bot_name=bot_name, api_key=api_key, tools=tools, tool_calls=tool_calls, tool_results=tool_results, access_key=access_key, access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel, session=session, on_error=on_error, num_tries=num_tries, retry_sleep_time=retry_sleep_time, base_url=base_url, extra_headers=extra_headers, ): yield message async def stream_request_base( request: QueryRequest, bot_name: str, api_key: str = "", *, tools: Optional[list[ToolDefinition]] = None, tool_calls: Optional[list[ToolCallDefinition]] = None, tool_results: Optional[list[ToolResultDefinition]] = None, access_key: str = "", access_key_deprecation_warning_stacklevel: int = 2, session: Optional[httpx.AsyncClient] = None, on_error: ErrorHandler = _default_error_handler, num_tries: int = 2, retry_sleep_time: float = 0.5, base_url: str = "https://api.poe.com/bot/", extra_headers: Optional[dict[str, str]] = None, ) -> AsyncGenerator[BotMessage, None]: if access_key != "": warnings.warn( "the access_key param is no longer necessary when using this function.", DeprecationWarning, stacklevel=access_key_deprecation_warning_stacklevel, ) async with contextlib.AsyncExitStack() as stack: if session is None: session = await stack.enter_async_context(httpx.AsyncClient(timeout=600)) url = f"{base_url}{bot_name}" ctx = _BotContext( endpoint=url, api_key=api_key, session=session, on_error=on_error, extra_headers=extra_headers, ) got_response = False for i in range(num_tries): try: async for message in ctx.perform_query_request( request=request, tools=tools, tool_calls=tool_calls, tool_results=tool_results, ): got_response = True yield message break except BotErrorNoRetry: raise except Exception as e: on_error(e, f"Bot request to {bot_name} failed on try {i}") # Want to retry on some errors even if we have streamed part of the request # RemoteProtocolError: peer closed connection without sending complete message body allow_retry_after_response = isinstance(e, httpx.RemoteProtocolError) if ( got_response and not allow_retry_after_response ) or i == num_tries - 1: # If it's a BotError, it probably has a good error message # that we want to show directly. if isinstance(e, BotError): raise # But if it's something else (maybe an HTTP error or something), # wrap it in a BotError that makes it clear which bot is broken. raise BotError(f"Error communicating with bot {bot_name}") from e await asyncio.sleep(retry_sleep_time) def get_bot_response( messages: list[ProtocolMessage], bot_name: str, api_key: str, *, tools: Optional[list[ToolDefinition]] = None, tool_executables: Optional[list[Callable]] = None, temperature: Optional[float] = None, skip_system_prompt: Optional[bool] = None, adopt_current_bot_name: Optional[bool] = None, logit_bias: Optional[dict[str, float]] = None, stop_sequences: Optional[list[str]] = None, 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` (`list[ProtocolMessage]`): A list of messages representing your conversation. - `bot_name` (`str`): The bot that you want to invoke. - `api_key` (`str`): Your Poe API key. Available at [poe.com/api_key](https://poe.com/api_key) - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects describing the functions you have. This is used for OpenAI function calling. - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding to the ToolDefinitions. This is used for OpenAI function calling. - `temperature` (`Optional[float] = None`): The temperature to use for the bot. - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt. - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot. - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot. - `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is mainly for internal testing and is not expected to be changed. - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt the identity of the calling bot - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot. """ additional_params = {} # This is so that we don't have to redefine the default values for these params. if temperature is not None: additional_params["temperature"] = temperature if skip_system_prompt is not None: additional_params["skip_system_prompt"] = skip_system_prompt if logit_bias is not None: additional_params["logit_bias"] = logit_bias if stop_sequences is not None: additional_params["stop_sequences"] = stop_sequences if adopt_current_bot_name is not None: additional_params["adopt_current_bot_name"] = adopt_current_bot_name query = QueryRequest( query=messages, user_id="", conversation_id="", message_id="", version=PROTOCOL_VERSION, type="query", **additional_params, ) return stream_request( request=query, bot_name=bot_name, api_key=api_key, tools=tools, tool_executables=tool_executables, base_url=base_url, session=session, ) def get_bot_response_sync( messages: list[ProtocolMessage], bot_name: str, api_key: str, *, tools: Optional[list[ToolDefinition]] = None, tool_executables: Optional[list[Callable]] = None, temperature: Optional[float] = None, skip_system_prompt: Optional[bool] = None, logit_bias: Optional[dict[str, float]] = None, adopt_current_bot_name: Optional[bool] = None, stop_sequences: Optional[list[str]] = None, base_url: str = "https://api.poe.com/bot/", session: Optional[httpx.AsyncClient] = None, ) -> Generator[BotMessage, None, None]: """ This function wraps the async generator `fp.get_bot_response` and returns partial responses synchronously. For asynchronous streaming, or integration into an existing event loop, use `fp.get_bot_response` directly. #### Parameters: - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation. - `bot_name` (`str`): The bot that you want to invoke. - `api_key` (`str`): Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key) - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects describing the functions you have. This is used for OpenAI function calling. - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding to the ToolDefinitions. This is used for OpenAI function calling. - `temperature` (`Optional[float] = None`): The temperature to use for the bot. - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt. - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot. - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot. - `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is mainly for internal testing and is not expected to be changed. - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt the identity of the calling bot - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot. """ async def _async_generator() -> AsyncGenerator[BotMessage, None]: async for partial in get_bot_response( messages=messages, bot_name=bot_name, api_key=api_key, tools=tools, tool_executables=tool_executables, temperature=temperature, skip_system_prompt=skip_system_prompt, adopt_current_bot_name=adopt_current_bot_name, logit_bias=logit_bias, stop_sequences=stop_sequences, base_url=base_url, session=session, ): yield partial def _sync_generator() -> Generator[BotMessage, None, None]: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) async_gen = _async_generator().__aiter__() try: while True: # Pull one item from the async generator at a time, # blocking until it’s ready. yield loop.run_until_complete(async_gen.__anext__()) except StopAsyncIteration: pass finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() return _sync_generator() async def get_final_response( request: QueryRequest, bot_name: str, api_key: str = "", *, access_key: str = "", session: Optional[httpx.AsyncClient] = None, on_error: ErrorHandler = _default_error_handler, num_tries: int = 2, retry_sleep_time: float = 0.5, base_url: str = "https://api.poe.com/bot/", ) -> str: """ A helper function for the bot query API that waits for all the tokens and concatenates the full response before returning. #### Parameters: - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object also includes information needed to identify the user for compute point usage. - `bot_name` (`str`): The bot you want to invoke. - `api_key` (`str = ""`): 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, bot_name, api_key, access_key=access_key, access_key_deprecation_warning_stacklevel=3, session=session, on_error=on_error, num_tries=num_tries, retry_sleep_time=retry_sleep_time, base_url=base_url, ): if isinstance(message, MetaMessage): continue if message.is_suggested_reply: continue if message.is_replace_response: chunks.clear() chunks.append(message.text) if not chunks: raise BotError(f"Bot {bot_name} sent no response") return "".join(chunks) def sync_bot_settings( bot_name: str, access_key: str = "", *, settings: Optional[dict[str, Any]] = None, base_url: str = "https://api.poe.com/bot/", ) -> None: """Fetch settings from the running bot server, and then sync them with Poe.""" try: if settings is None: response = httpx.post( f"{base_url}fetch_settings/{bot_name}/{access_key}/{PROTOCOL_VERSION}" ) else: headers = {"Content-Type": "application/json"} response = httpx.post( f"{base_url}update_settings/{bot_name}/{access_key}/{PROTOCOL_VERSION}", headers=headers, json=settings, ) if response.status_code != 200: raise BotError( f"Error syncing settings for bot {bot_name}: {response.text}" ) except httpx.ReadTimeout as e: error_message = f"Timeout syncing settings for bot {bot_name}." if not settings: error_message += " Check that the bot server is running." raise BotError(error_message) from e print(response.text) async def upload_file( file: Optional[Union[bytes, BinaryIO]] = None, file_url: Optional[str] = None, file_name: Optional[str] = None, api_key: str = "", *, session: Optional[httpx.AsyncClient] = None, on_error: ErrorHandler = _default_error_handler, num_tries: int = 2, retry_sleep_time: float = 0.5, base_url: str = "https://www.quora.com/poe_api/", extra_headers: Optional[dict[str, str]] = None, ) -> Attachment: """ Upload a file (raw bytes *or* via URL) to Poe and receive an Attachment object that can be returned directly from a bot or stored for later use. #### Parameters: - `file` (`Optional[Union[bytes, BinaryIO]] = None`): The file to upload. - `file_url` (`Optional[str] = None`): The URL of the file to upload. - `file_name` (`Optional[str] = None`): The name of the file to upload. Required if `file` is provided as raw bytes. - `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. This can also be the `access_key` if called from a Poe server bot. #### Returns: - `Attachment`: An Attachment object representing the uploaded file. """ if not api_key: raise ValueError( "`api_key` is required (generate one at https://poe.com/api_key)" ) if (file is None and file_url is None) or (file and file_url): raise ValueError("Provide either `file` or `file_url`, not both.") if file is not None and not file_name: if isinstance(file, io.IOBase): potential = getattr(file, "name", "") if potential: file_name = os.path.basename(potential) if not file_name: raise ValueError( "`file_name` is mandatory when file object has no name attribute." ) elif isinstance(file, (bytes, bytearray)): raise ValueError("`file_name` is mandatory when sending raw bytes.") else: raise ValueError("unsupported file type") endpoint = base_url.rstrip("/") + "/file_upload_3RD_PARTY_POST" async def _do_upload(_session: httpx.AsyncClient) -> Attachment: headers = {"Authorization": api_key} if extra_headers is not None: headers.update(extra_headers) if file_url: data: dict[str, str] = {"download_url": file_url} if file_name: data["download_filename"] = file_name request = _session.build_request( "POST", endpoint, data=data, headers=headers ) else: # raw bytes / BinaryIO assert ( file is not None ), "file is required if file_url is not provided" # pyright file_data = ( file.read() if not isinstance(file, (bytes, bytearray)) else file ) files = {"file": (file_name, file_data)} request = _session.build_request( "POST", endpoint, files=files, headers=headers ) response = await _session.send(request) if response.status_code != 200: # collect full error text (endpoint streams errors) try: err_txt = await response.aread() except Exception: err_txt = response.text raise AttachmentUploadError( f"{response.status_code} {response.reason_phrase}: {err_txt}" ) data = response.json() if not {"attachment_url", "mime_type"}.issubset(data): raise AttachmentUploadError(f"Unexpected response format: {data}") return Attachment( url=data["attachment_url"], content_type=data["mime_type"], name=file_name or "file", ) # retry wrapper _sess = session or httpx.AsyncClient(timeout=120) async with _sess: for attempt in range(num_tries): try: return await _do_upload(_sess) except Exception as e: on_error(e, f"upload attempt {attempt+1}/{num_tries} failed") if attempt == num_tries - 1: raise await asyncio.sleep(retry_sleep_time) raise AssertionError("retries exhausted") # unreachable, but satisfies pyright def upload_file_sync( file: Optional[Union[bytes, BinaryIO]] = None, file_url: Optional[str] = None, file_name: Optional[str] = None, api_key: str = "", *, session: Optional[httpx.AsyncClient] = None, on_error: ErrorHandler = _default_error_handler, num_tries: int = 2, retry_sleep_time: float = 0.5, base_url: str = "https://www.quora.com/poe_api/", extra_headers: Optional[dict[str, str]] = None, ) -> Attachment: """ This is a synchronous wrapper around the async `upload_file`. """ coro = upload_file( file=file, file_url=file_url, file_name=file_name, api_key=api_key, session=session, on_error=on_error, num_tries=num_tries, retry_sleep_time=retry_sleep_time, base_url=base_url, extra_headers=extra_headers, ) return run_sync(coro, session=session) ================================================ FILE: src/fastapi_poe/py.typed ================================================ ================================================ FILE: src/fastapi_poe/sync_utils.py ================================================ """ Utility helpers for running async functions from synchronous code. 1. If there is no running event loop, just `asyncio.run`. 2. If there is a running loop, spin up a background thread that has its own loop and execute the coroutine there. 3. If the caller passes in an `httpx.AsyncClient` (or any external resource bound to the outer loop) while a loop is already running, raise because that resource cannot be reused safely in the thread. """ from __future__ import annotations import asyncio import concurrent.futures from collections.abc import Coroutine from typing import Any, Callable, TypeVar T = TypeVar("T") def run_sync( coro: Coroutine[Any, Any, T], *, session: object | None = None, _executor_factory: Callable[[], concurrent.futures.Executor] | None = None, ) -> T: """ Run *coro* synchronously and return its result. Parameters ---------- coro: Any awaitable (usually an async function call). session: Optional resource tied to the outer loop; if supplied while already inside a loop we refuse to continue. _executor_factory: *Test seam* - lets tests inject a dummy executor. Raises ------ ValueError If called from inside an event loop *and* ``session`` is passed in. """ # ────────────────────────────── # 1. No running loop # ────────────────────────────── try: asyncio.get_running_loop() except RuntimeError: return asyncio.run(coro) # ────────────────────────────── # 2. Running loop # ────────────────────────────── if session is not None: raise ValueError( "run_sync called from within an async environment but a " "`session` bound to that loop was supplied. Pass no session " "or call the async variant directly." ) def _runner() -> T: return asyncio.run(coro) executor = ( _executor_factory() if _executor_factory is not None else concurrent.futures.ThreadPoolExecutor(max_workers=1) ) # Use a context manager only when we created the executor ourselves. if _executor_factory is None: with executor: future = executor.submit(_runner) return future.result() else: # In tests where a fake executor is injected we assume it stays alive. future = executor.submit(_runner) return future.result() ================================================ FILE: src/fastapi_poe/templates.py ================================================ """ This module contains a collection of string templates designed to streamline the integration of features like attachments into the LLM request. """ TEXT_ATTACHMENT_TEMPLATE = ( """Below is the content of {attachment_name}:\n\n{attachment_parsed_content}""" ) URL_ATTACHMENT_TEMPLATE = ( """Assume you can access the external URL {attachment_name}. """ """Use the URL's content below to respond to the queries:\n\n{content}""" ) IMAGE_VISION_ATTACHMENT_TEMPLATE = ( """I have uploaded an image ({filename}). """ """Assume that you can see the attached image. """ """First, read the image analysis:\n\n""" """{parsed_image_description}\n\n""" """Use any relevant parts to inform your response. """ """Do NOT reference the image analysis in your response. """ """Respond in the same language as my next message. """ ) ================================================ FILE: src/fastapi_poe/types.py ================================================ import math from typing import Any, Optional, Union, cast, get_args from fastapi import Request from pydantic import BaseModel, ConfigDict, Field, field_validator from typing_extensions import Literal, TypeAlias Identifier: TypeAlias = str FeedbackType: TypeAlias = Literal["like", "dislike"] ContentType: TypeAlias = Literal["text/markdown", "text/plain"] MessageType: TypeAlias = Literal["function_call"] ErrorType: TypeAlias = Literal[ "user_message_too_long", "insufficient_fund", "user_caused_error", "privacy_authorization_error", ] class MessageFeedback(BaseModel): """ Feedback for a message as used in the Poe protocol. #### Fields: - `type` (`FeedbackType`) - `reason` (`Optional[str]`) """ type: FeedbackType reason: Optional[str] class CostItem(BaseModel): """ An object representing a cost item used for authorization and charge request. #### Fields: - `amount_usd_milli_cents` (`int`) - `description` (`str`) """ amount_usd_milli_cents: int description: Optional[str] = None @field_validator("amount_usd_milli_cents", mode="before") def validate_amount_is_int(cls, v: Union[int, str, float]) -> int: if isinstance(v, float): return math.ceil(v) if not isinstance(v, int): raise ValueError( "Invalid amount: expected an integer for amount_usd_milli_cents, " f"got {type(v)}. Please provide the amount in milli-cents " "(1/1000 of a cent) as a whole number. If you're working with a " "decimal value, consider using math.ceil() to round up." ) return v class Attachment(BaseModel): """ Attachment included in a protocol message. #### Fields: - `url` (`str`): The download URL of the attachment. - `content_type` (`str`): The MIME type of the attachment. - `name` (`str`): The name of the attachment. - `inline_ref` (`Optional[str] = None`): Set this to make Poe render the attachment inline. You can then reference the attachment inline using ![title][inline_ref]. - `parsed_content` (`Optional[str] = None`): The parsed content of the attachment. """ url: str content_type: str name: str inline_ref: Optional[str] = None parsed_content: Optional[str] = None class MessageReaction(BaseModel): """ Reaction to a message. #### Fields: - `user_id` (`Identifier`): An anonymized identifier representing the user who reacted to the message. - `reaction` (`str`): The reaction to the message. """ user_id: Identifier reaction: str class Sender(BaseModel): """ Sender of a message. #### Fields: - `id` (`Optional[Identifier] = None`): An anonymized identifier representing the sender. - `name` (`Optional[str] = None`): The name of the sender. If sender is a bot, this will be the name of the bot. If sender is a user, this will be the name of the user if user name is available for this chat. Typically, user name is only available in a chat of multiple users. Please note that a user can change their name anytime and different users with different `id` can share the same name. """ id: Optional[Identifier] = None name: Optional[str] = None class User(BaseModel): """ User in a chat. #### Fields: - `id` (`Identifier`): An anonymized identifier representing a user. - `name` (`Optional[str] = None`): The name of the user if user name is available for this chat. Typically, user name is only available in a chat of multiple users. Please note that a user can change their name anytime and different users with different `id` can share the same name. """ id: Identifier name: Optional[str] = None class ProtocolMessage(BaseModel): """ A message as used in the Poe protocol. #### Fields: - `role` (`Literal["system", "user", "bot", "tool"]`): Message sender role. - `message_type` (`Optional[MessageType] = None`): Type of the message. - `sender_id` (`Optional[str]`): Sender ID of the message. This is deprecated, use `sender` instead. - `sender` (`Optional[Sender] = None`): Sender of the message. - `content` (`str`): Content of the message. - `parameters` (`dict[str, Any] = {}`): Parameters for the message. - `content_type` (`ContentType="text/markdown"`): Content type of the message. - `timestamp` (`int = 0`): Timestamp of the message. - `message_id` (`str = ""`): Message ID for the message. - `feedback` (`list[MessageFeedback] = []`): Feedback for the message. - `attachments` (`list[Attachment] = []`): Attachments for the message. - `metadata` (`Optional[str] = None`): Metadata associated with the message. - `referenced_message` (`Optional["ProtocolMessage"] = None`): Message referenced by this message (if any). - `reactions` (`list[MessageReaction] = []`): Reactions to the message. """ role: Literal["system", "user", "bot", "tool"] message_type: Optional[MessageType] = None sender_id: Optional[str] = None sender: Optional[Sender] = None content: str parameters: dict[str, Any] = {} content_type: ContentType = "text/markdown" timestamp: int = 0 message_id: str = "" feedback: list[MessageFeedback] = Field(default_factory=list) attachments: list[Attachment] = Field(default_factory=list) metadata: Optional[str] = None referenced_message: Optional["ProtocolMessage"] = None reactions: list[MessageReaction] = Field(default_factory=list) class RequestContext(BaseModel): class Config: arbitrary_types_allowed = True http_request: Request class ParametersDefinition(BaseModel): """ Parameters definition for function calling. #### Fields: - `type` (`str`) - `properties` (`dict[str, object]`) - `required` (`Optional[list[str]]`) """ type: str # noqa: A003 properties: dict[str, object] required: Optional[list[str]] = None class FunctionDefinition(BaseModel): """ Function definition for OpenAI function calling. #### Fields: - `name` (`str`) - `description` (`str`) - `parameters` (`ParametersDefinition`) """ name: str description: str parameters: ParametersDefinition class ToolDefinition(BaseModel): """ An object representing a tool definition used for OpenAI function calling. #### Fields: - `type` (`str`) - `function` (`FunctionDefinition`): Look at the source code for a detailed description of what this means. """ type: str function: FunctionDefinition class CustomToolDefinition(BaseModel): """Custom tool definition for OpenAI-compatible custom tools. Corresponds to `chat_completion_custom_tool_param.Custom` but with a looser format specification. """ name: str description: Optional[str] = None format_: Optional[dict[str, Any]] = Field(default=None, alias="format") model_config = ConfigDict(populate_by_name=True) class FunctionCallDefinition(BaseModel): """ Function call definition for OpenAI function calling. #### Fields: - `name` (`str`) - `arguments` (`str`) """ name: str arguments: str class ToolCallDefinition(BaseModel): """ An object representing a tool call. This is returned as a response by the model when using OpenAI function calling. #### Fields: - `id` (`str`) - `type` (`str`) - `function` (`FunctionCallDefinition`): The function name (string) and arguments (JSON string). """ id: str type: str function: FunctionCallDefinition class CustomCallDefinition(BaseModel): """Custom tool call in model response. Corresponds to `chat_completion_message_custom_tool_call.Custom`. """ name: str input_: str = Field(alias="input") model_config = ConfigDict(populate_by_name=True) class ToolResultDefinition(BaseModel): """ An object representing a function result. This is passed to the model in the last step when using OpenAI function calling. #### Fields: - `role` (`str`) - `name` (`str`) - `tool_call_id` (`str`) - `content` (`str`) """ role: str name: str tool_call_id: str content: str class FunctionCallDefinitionDelta(BaseModel): """ Function call definition delta for streaming OpenAI function calling. #### Fields: - `name` (`Optional[str]`) - `arguments` (`str`) """ name: Optional[str] = None arguments: str class ToolCallDefinitionDelta(BaseModel): """ An object representing a tool call chunk. This is returned as a streamed response by the model when using OpenAI function calling. This may be an incomplete tool call definition (e.g. with the function name set with the arguments not yet filled in), so the index can be used to identify which tool call this chunk belongs to. Chunks may have null id, type, and function.name values. See https://platform.openai.com/docs/guides/function-calling#streaming for examples. #### Fields: - `index` (`int`): used to identify to which tool call this chunk belongs. - `id` (`Optional[str] = None`): The tool call ID. This helps the model identify previous tool call suggestions and help optimize tool call loops. - `type` (`Optional[str] = None`): The type of the tool call (always function for function calls). - `function` (`FunctionCallDefinitionDelta`): The function name (string) and arguments (JSON string). """ index: int = 0 id: Optional[str] = None type: Optional[str] = None function: FunctionCallDefinitionDelta class BaseRequest(BaseModel): """Common data for all requests.""" version: str type: Literal[ "query", "settings", "report_feedback", "report_reaction", "report_error" ] class QueryRequest(BaseRequest): """ Request parameters for a query request. #### Fields: - `query` (`list[ProtocolMessage]`): list of message representing the current state of the chat. - `user_id` (`Identifier`): an anonymized identifier representing a user. This is persistent for subsequent requests from that user. - `conversation_id` (`Identifier`): an identifier representing a chat. This is persistent for subsequent request for that chat. - `message_id` (`Identifier`): an identifier representing a message. - `access_key` (`str = ""`): contains the access key defined when you created your bot on Poe. - `temperature` (`float | None = None`): Temperature input to be used for model inference. - `skip_system_prompt` (`bool = False`): Whether to use any system prompting or not. - `logit_bias` (`dict[str, float] = {}`) - `stop_sequences` (`list[str] = []`) - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt the identity of the calling bot - `language_code` (`str = "en"`): BCP 47 language code of the user's client. - `bot_query_id` (`str = ""`): an identifier representing a bot query. - `users` (`list[User] = []`): list of users in the chat. - `tools` (`Optional[list[ToolDefinition]] = None`): List of tool definitions for function calling. - `tool_calls` (`Optional[list[ToolCallDefinition]] = None`): List of tool calls made by the assistant. - `tool_results` (`Optional[list[ToolResultDefinition]] = None`): List of tool execution results. - `query_creation_time` (`Optional[int] = None`): Timestamp when the query was created (microseconds). - `extra_params` (`Optional[dict[str, Any]] = None`): Additional parameters for the request. """ query: list[ProtocolMessage] user_id: Identifier conversation_id: Identifier message_id: Identifier metadata: Identifier = "" api_key: str = "" access_key: str = "" temperature: Optional[float] = None skip_system_prompt: bool = False logit_bias: dict[str, float] = {} stop_sequences: list[str] = [] language_code: str = "en" adopt_current_bot_name: Optional[bool] = None bot_query_id: Identifier = "" users: list[User] = [] # Fields below are for compatibility with aiohttp_poe.types.QueryRequest tools: Optional[list[ToolDefinition]] = None tool_calls: Optional[list[ToolCallDefinition]] = None tool_results: Optional[list[ToolResultDefinition]] = None query_creation_time: Optional[int] = None extra_params: Optional[dict[str, Any]] = None @classmethod def from_dict(cls, data: dict[str, Any]) -> "QueryRequest": """Create QueryRequest from a dictionary (e.g., from aiohttp_poe format).""" # Convert tool definitions if present if "tools" in data and data["tools"] is not None: data["tools"] = [ ToolDefinition.model_validate(tool) if isinstance(tool, dict) else tool for tool in data["tools"] ] # Convert tool calls if present if "tool_calls" in data and data["tool_calls"] is not None: data["tool_calls"] = [ ToolCallDefinition.model_validate(tc) if isinstance(tc, dict) else tc for tc in data["tool_calls"] ] # Convert tool results if present if "tool_results" in data and data["tool_results"] is not None: data["tool_results"] = [ ToolResultDefinition.model_validate(tr) if isinstance(tr, dict) else tr for tr in data["tool_results"] ] return cls.model_validate(data) class SettingsRequest(BaseRequest): """ Request parameters for a settings request. Currently, this contains no fields but this might get updated in the future. """ class ReportFeedbackRequest(BaseRequest): """ Request parameters for a report_feedback request. #### Fields: - `message_id` (`Identifier`) - `user_id` (`Identifier`) - `conversation_id` (`Identifier`) - `feedback_type` (`FeedbackType`) """ message_id: Identifier user_id: Identifier conversation_id: Identifier feedback_type: FeedbackType class ReportReactionRequest(BaseRequest): """ Request parameters for a report_reaction request. #### Fields: - `message_id` (`Identifier`) - `user_id` (`Identifier`) - `conversation_id` (`Identifier`) - `reaction` (`str`) """ message_id: Identifier user_id: Identifier conversation_id: Identifier reaction: str class ReportErrorRequest(BaseRequest): """ Request parameters for a report_error request. #### Fields: - `message` (`str`) - `metadata` (`dict[str, Any]`) """ message: str metadata: dict[str, Any] Number = Union[int, float] class Divider(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["divider"] = "divider" class TextField(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["text_field"] = "text_field" label: str description: Optional[str] = None parameter_name: str default_value: Optional[str] = None placeholder: Optional[str] = None class TextArea(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["text_area"] = "text_area" label: str description: Optional[str] = None parameter_name: str default_value: Optional[str] = None placeholder: Optional[str] = None class ValueNamePair(BaseModel): model_config = ConfigDict(extra="forbid") value: str name: str class DropDown(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["drop_down"] = "drop_down" label: str description: Optional[str] = None parameter_name: str default_value: Optional[str] = None options: list[ValueNamePair] class ToggleSwitch(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["toggle_switch"] = "toggle_switch" label: str description: Optional[str] = None parameter_name: str default_value: Optional[bool] = None class Slider(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["slider"] = "slider" label: str description: Optional[str] = None parameter_name: str default_value: Optional[Number] = None min_value: Number max_value: Number step: Number class AspectRatioOption(BaseModel): model_config = ConfigDict(extra="forbid") value: Optional[str] = None width: Number height: Number class AspectRatio(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["aspect_ratio"] = "aspect_ratio" label: str description: Optional[str] = None parameter_name: str default_value: Optional[str] = None options: list[AspectRatioOption] BaseControl = Union[ Divider, TextField, TextArea, DropDown, ToggleSwitch, Slider, AspectRatio ] class LiteralValue(BaseModel): model_config = ConfigDict(extra="forbid") literal: Union[str, float, int, bool] class ParameterValue(BaseModel): model_config = ConfigDict(extra="forbid") parameter_name: str class ComparatorCondition(BaseModel): model_config = ConfigDict(extra="forbid") comparator: Literal["eq", "ne", "gt", "ge", "lt", "le"] left: Union[LiteralValue, ParameterValue] right: Union[LiteralValue, ParameterValue] class ConditionallyRenderControls(BaseModel): model_config = ConfigDict(extra="forbid") control: Literal["condition"] = "condition" condition: ComparatorCondition controls: list[BaseControl] FullControls = Union[ Divider, TextField, TextArea, DropDown, ToggleSwitch, Slider, AspectRatio, ConditionallyRenderControls, ] class Tab(BaseModel): model_config = ConfigDict(extra="forbid") name: Optional[str] = None controls: list[FullControls] class Section(BaseModel): model_config = ConfigDict(extra="forbid") name: Optional[str] = None controls: Optional[list[FullControls]] = None tabs: Optional[list[Tab]] = None collapsed_by_default: Optional[bool] = None class ParameterControls(BaseModel): model_config = ConfigDict(extra="forbid") api_version: Literal["2"] = "2" sections: list[Section] class SettingsResponse(BaseModel): """ An object representing your bot's response to a settings object. #### Fields: - `response_version` (`int = 2`): Different Poe Protocol versions use different default settings values. When provided, Poe will use the default values for the specified response version. If not provided, Poe will use the default values for response version 0. - `server_bot_dependencies` (`dict[str, int] = {}`): Information about other bots that your bot uses. This is used to facilitate the Bot Query API. - `allow_attachments` (`bool = True`): Whether to allow users to upload attachments to your bot. - `introduction_message` (`str = ""`): The introduction message to display to the users of your bot. - `expand_text_attachments` (`bool = True`): Whether to request parsed content/descriptions from text attachments with the query request. This content is sent through the new parsed_content field in the attachment dictionary. This change makes enabling file uploads much simpler. - `enable_image_comprehension` (`bool = False`): Similar to `expand_text_attachments` but for images. - `enforce_author_role_alternation` (`bool = False`): If enabled, Poe will concatenate messages so that they follow role alternation, which is a requirement for certain LLM providers like Anthropic. - `enable_multi_entity_prompting` (`bool = True`): If enabled, Poe will combine previous bot messages if there is a multientity context. - `parameter_controls` (`Optional[ParameterControls] = None`): Optional JSON object that defines interactive parameter controls. The object must contain an api_version and sections array. """ model_config = ConfigDict(extra="forbid") response_version: Optional[int] = 2 context_clear_window_secs: Optional[int] = None # deprecated allow_user_context_clear: Optional[bool] = None # deprecated custom_rate_card: Optional[str] = None # deprecated server_bot_dependencies: dict[str, int] = Field(default_factory=dict) allow_attachments: Optional[bool] = None introduction_message: Optional[str] = None expand_text_attachments: Optional[bool] = None enable_image_comprehension: Optional[bool] = None enforce_author_role_alternation: Optional[bool] = None enable_multi_bot_chat_prompting: Optional[bool] = None # deprecated enable_multi_entity_prompting: Optional[bool] = None rate_card: Optional[str] = None cost_label: Optional[str] = None parameter_controls: Optional[ParameterControls] = None class AttachmentUploadResponse(BaseModel): """ The result of a post_message_attachment request or file event in bot response. #### Fields: - `attachment_url` (`Optional[str]`): The URL of the attachment. - `mime_type` (`Optional[str]`): The MIME type of the attachment. - `name` (`Optional[str]`): The name of the attachment. Only populated when the attachment originates from a file event in bot response, not from post_message_attachment. - `inline_ref` (`Optional[str]`): The inline reference of the attachment. if post_message_attachment is called with is_inline=False, this will be None. """ attachment_url: Optional[str] mime_type: Optional[str] name: Optional[str] = None inline_ref: Optional[str] @classmethod def from_dict(cls, data: dict[str, object]) -> "AttachmentUploadResponse": """Create an AttachmentUploadResponse from a dictionary (for aiohttp_poe FileEvent).""" return cls( attachment_url=data.get("url"), # type: ignore mime_type=data.get("content_type"), # type: ignore name=data.get("name"), # type: ignore inline_ref=data.get("inline_ref"), # type: ignore ) class AttachmentHttpResponse(BaseModel): attachment_url: Optional[str] mime_type: Optional[str] class DataResponse(BaseModel): """ A response that contains arbitrary data to attach to the bot response. This data can be retrieved in later requests to the bot within the same chat. Note that only the final DataResponse object in the stream will be attached to the bot response. #### Fields: - `metadata` (`str`): String of data to attach to the bot response. """ model_config = ConfigDict(extra="forbid") metadata: str class PartialResponse(BaseModel): """ 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. #### Fields: - `text` (`str`): 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` (`Optional[dict[str, Any]]`): Used to send arbitrary json data to Poe. This is currently only used for OpenAI function calling. - `is_suggested_reply` (`bool = False`): Setting this to true will create a suggested reply with the provided text value. - `is_replace_response` (`bool = False`): 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. model_config = ConfigDict(extra="forbid") text: str """Partial response text. If the final bot response is "ABC", you may see a sequence of PartialResponse objects like PartialResponse(text="A"), PartialResponse(text="B"), PartialResponse(text="C"). """ data: Optional[dict[str, Any]] = None """Used when a bot returns the json event.""" raw_response: object = None """For debugging, the raw response from the bot.""" full_prompt: Optional[str] = None """For debugging, contains the full prompt as sent to the bot.""" request_id: Optional[str] = None """May be set to an internal identifier for the request.""" is_suggested_reply: bool = False """If true, this is a suggested reply.""" is_replace_response: bool = False """If true, this text should completely replace the previous bot text.""" attachment: Optional[Attachment] = None """If the bot returns an attachment, it will be contained here.""" tool_calls: list[ToolCallDefinitionDelta] = Field(default_factory=list) """If the bot returns tool calls, it will be contained here.""" index: Optional[int] = None """If a bot supports multiple responses, this is the index of the response to be updated.""" @classmethod def from_dict(cls, data: dict[str, object]) -> "PartialResponse": """Create a PartialResponse from a dictionary (for aiohttp_poe TextEvent).""" return cls(text=str(data.get("text", ""))) class ErrorResponse(PartialResponse): """ Similar to `PartialResponse`. Yield this to communicate errors from your bot. #### Fields: - `allow_retry` (`bool = True`): Whether or not to allow a user to retry on error. - `error_type` (`Optional[ErrorType] = None`): An enum indicating what error to display. """ allow_retry: bool = True error_type: Optional[ErrorType] = None @classmethod def from_dict(cls, data: dict[str, object]) -> "ErrorResponse": """Create an ErrorResponse from a dictionary (for aiohttp_poe ErrorEvent).""" text = data.get("text", "") allow_retry = data.get("allow_retry", True) error_type_raw = data.get("error_type") error_type: Optional[ErrorType] = None if isinstance(error_type_raw, str) and error_type_raw in get_args(ErrorType): error_type = cast(ErrorType, error_type_raw) return cls(text=str(text), allow_retry=bool(allow_retry), error_type=error_type) class MetaResponse(PartialResponse): """ Similar to `Partial Response`. Yield this to communicate `meta` events from server bots. #### Fields: - `suggested_replies` (`bool = False`): Whether or not to enable suggested replies. - `content_type` (`ContentType = "text/markdown"`): Used to describe the format of the response. The currently supported values are `text/plain` and `text/markdown`. - `refetch_settings` (`bool = False`): Used to trigger a settings fetch request from Poe. A more robust way to trigger this is documented at: https://creator.poe.com/docs/server-bots/updating-bot-settings """ linkify: bool = True # deprecated suggested_replies: bool = True content_type: ContentType = "text/markdown" refetch_settings: bool = False ================================================ FILE: tests/test_base.py ================================================ import json from collections.abc import AsyncIterable, AsyncIterator from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Callable, Union from unittest.mock import AsyncMock, Mock, patch import httpx import pytest from fastapi import Request from fastapi_poe.base import CostRequestError, InsufficientFundError, PoeBot, make_app from fastapi_poe.client import AttachmentUploadError from fastapi_poe.templates import ( IMAGE_VISION_ATTACHMENT_TEMPLATE, TEXT_ATTACHMENT_TEMPLATE, URL_ATTACHMENT_TEMPLATE, ) from fastapi_poe.types import ( Attachment, AttachmentUploadResponse, CostItem, DataResponse, ErrorResponse, MetaResponse, PartialResponse, ProtocolMessage, QueryRequest, RequestContext, Sender, ) from sse_starlette import ServerSentEvent from starlette.routing import Route @pytest.fixture def basic_bot() -> PoeBot: mock_bot = PoeBot(path="/bot/test_bot", bot_name="test_bot", access_key="123") async def get_response( request: QueryRequest, ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]: yield MetaResponse( text="", suggested_replies=True, content_type="text/markdown", refetch_settings=False, ) yield PartialResponse(text="hello") yield PartialResponse(text="this is a suggested reply", is_suggested_reply=True) yield PartialResponse( text="this is a replace response", is_replace_response=True ) yield DataResponse(metadata='{"foo": "bar"}') mock_bot.get_response = get_response return mock_bot @pytest.fixture def attachment_bot() -> PoeBot: mock_bot = PoeBot( path="/bot/attachment_bot", bot_name="attachment_bot", access_key="123" ) async def get_response( request: QueryRequest, ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]: yield PartialResponse(text="Generating... (1s elapsed)") yield PartialResponse( text="Generating... (2s elapsed)", is_replace_response=True ) yield PartialResponse( text="Generating... (3s elapsed)", is_replace_response=True ) inline_ref = "abc" yield PartialResponse( text=f"![image][{inline_ref}]", attachment=Attachment( url="https://pfst.cf2.poecdn.net/base/image/cat.jpg", name="cat.jpg", content_type="image/jpeg", inline_ref=inline_ref, ), is_replace_response=True, ) # test a non-inline attachment yield PartialResponse( text="", attachment=Attachment( url="https://pfst.cf2.poecdn.net/base/application/test.pdf", name="test.pdf", content_type="application/pdf", ), ) mock_bot.get_response = get_response return mock_bot @pytest.fixture def error_bot() -> PoeBot: mock_bot = PoeBot(path="/bot/error_bot", bot_name="error_bot", access_key="123") async def get_response( request: QueryRequest, ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]: yield PartialResponse(text="hello") yield ErrorResponse(text="sample error", allow_retry=True) mock_bot.get_response = get_response return mock_bot @pytest.fixture def mock_request() -> QueryRequest: return QueryRequest( version="1.0", type="query", query=[ProtocolMessage(role="user", content="Hello, world!", sender=Sender())], user_id="123", conversation_id="123", message_id="456", bot_query_id="123", ) @pytest.fixture def mock_request_context() -> RequestContext: return RequestContext(http_request=Mock(spec=Request)) class TestPoeBot: @pytest.mark.asyncio async def test_handle_query_basic_bot( self, basic_bot: PoeBot, mock_request: QueryRequest, mock_request_context: RequestContext, ) -> None: expected_sse_events = [ ServerSentEvent( event="meta", data=json.dumps( { "suggested_replies": True, "content_type": "text/markdown", "refetch_settings": False, "linkify": True, } ), ), ServerSentEvent(event="text", data=json.dumps({"text": "hello"})), ServerSentEvent( event="suggested_reply", data=json.dumps({"text": "this is a suggested reply"}), ), ServerSentEvent( event="replace_response", data=json.dumps({"text": "this is a replace response"}), ), ServerSentEvent( event="data", data=json.dumps({"metadata": '{"foo": "bar"}'}) ), ServerSentEvent(event="done", data="{}"), ] actual_sse_events = [ event async for event in basic_bot.handle_query( mock_request, mock_request_context ) ] assert len(actual_sse_events) == len(expected_sse_events) for actual_event, expected_event in zip(actual_sse_events, expected_sse_events): assert actual_event.event == expected_event.event assert expected_event.data and actual_event.data assert json.loads(actual_event.data) == json.loads(expected_event.data) @pytest.mark.asyncio async def test_handle_query_error_bot( self, error_bot: PoeBot, mock_request: QueryRequest, mock_request_context: RequestContext, ) -> None: expected_sse_events_error = [ ServerSentEvent(event="text", data=json.dumps({"text": "hello"})), ServerSentEvent( event="error", data=json.dumps({"text": "sample error", "allow_retry": True}), ), ServerSentEvent(event="done", data="{}"), ] actual_sse_events = [ event async for event in error_bot.handle_query( mock_request, mock_request_context ) ] assert len(actual_sse_events) == len(expected_sse_events_error) for actual_event, expected_event in zip( actual_sse_events, expected_sse_events_error ): assert actual_event.event == expected_event.event assert expected_event.data and actual_event.data assert json.loads(actual_event.data) == json.loads(expected_event.data) def test_insert_attachment_messages(self, basic_bot: PoeBot) -> None: # Create mock attachments mock_text_attachment = Attachment( url="https://pfst.cf2.poecdn.net/base/text/test.txt", name="test.txt", content_type="text/plain", parsed_content="Hello, world!", ) mock_image_attachment = Attachment( url="https://pfst.cf2.poecdn.net/base/image/test.png", name="test.png", content_type="image/png", parsed_content="test.png***Hello, world!", ) mock_image_attachment_2 = Attachment( url="https://pfst.cf2.poecdn.net/base/image/test.png", name="testimage2.jpg", content_type="image/jpeg", parsed_content="Hello, world!", ) mock_pdf_attachment = Attachment( url="https://pfst.cf2.poecdn.net/base/application/test.pdf", name="test.pdf", content_type="application/pdf", parsed_content="Hello, world!", ) mock_html_attachment = Attachment( url="https://pfst.cf2.poecdn.net/base/text/test.html", name="test.html", content_type="text/html", parsed_content="Hello, world!", ) mock_video_attachment = Attachment( url="https://pfst.cf2.poecdn.net/base/video/test.mp4", name="test.mp4", content_type="video/mp4", parsed_content="Hello, world!", ) # Create mock protocol messages message_without_attachments = ProtocolMessage( role="user", content="Hello, world!", sender=Sender() ) message_with_attachments = ProtocolMessage( role="user", content="Here's some attachments", sender=Sender(), attachments=[ mock_text_attachment, mock_image_attachment, mock_image_attachment_2, mock_pdf_attachment, mock_html_attachment, mock_video_attachment, ], ) # Create mock query request mock_query_request = QueryRequest( version="1.0", type="query", query=[message_without_attachments, message_with_attachments], user_id="123", conversation_id="123", message_id="456", ) assert ( mock_image_attachment.parsed_content ) # satisfy pyright so split() works below expected_protocol_messages = [ message_without_attachments, ProtocolMessage( role="user", sender=Sender(), content=TEXT_ATTACHMENT_TEMPLATE.format( attachment_name=mock_text_attachment.name, attachment_parsed_content=mock_text_attachment.parsed_content, ), ), ProtocolMessage( role="user", sender=Sender(), content=TEXT_ATTACHMENT_TEMPLATE.format( attachment_name=mock_pdf_attachment.name, attachment_parsed_content=mock_pdf_attachment.parsed_content, ), ), ProtocolMessage( role="user", sender=Sender(), content=URL_ATTACHMENT_TEMPLATE.format( attachment_name=mock_html_attachment.name, content=mock_html_attachment.parsed_content, ), ), ProtocolMessage( role="user", sender=Sender(), content=IMAGE_VISION_ATTACHMENT_TEMPLATE.format( filename=mock_image_attachment.parsed_content.split("***")[0], parsed_image_description=mock_image_attachment.parsed_content.split( "***" )[1], ), ), ProtocolMessage( role="user", sender=Sender(), content=IMAGE_VISION_ATTACHMENT_TEMPLATE.format( filename=mock_image_attachment_2.name, parsed_image_description=mock_image_attachment_2.parsed_content, ), ), message_with_attachments, ] modified_query_request = basic_bot.insert_attachment_messages( mock_query_request ) protocol_messages = modified_query_request.query assert protocol_messages == expected_protocol_messages def test_make_prompt_author_role_alternated(self, basic_bot: PoeBot) -> None: mock_protocol_messages = [ ProtocolMessage( role="user", sender=Sender(), content="Hello, world!", attachments=[ Attachment( url="https://pfst.cf2.poecdn.net/base/text/test.txt", name="test.txt", content_type="text/plain", parsed_content="Hello, world!", ) ], ), ProtocolMessage( role="user", sender=Sender(), content="Hello, world!", attachments=[ Attachment( url="https://pfst.cf2.poecdn.net/base/text/test2.txt", name="test2.txt", content_type="text/plain", parsed_content="Bye!", ) ], ), ProtocolMessage(role="bot", sender=Sender(), content="Hello, world!"), ] expected_protocol_messages = [ ProtocolMessage( role="user", sender=Sender(), content="Hello, world!\n\nHello, world!", attachments=[ Attachment( url="https://pfst.cf2.poecdn.net/base/text/test2.txt", name="test2.txt", content_type="text/plain", parsed_content="Bye!", ), Attachment( url="https://pfst.cf2.poecdn.net/base/text/test.txt", name="test.txt", content_type="text/plain", parsed_content="Hello, world!", ), ], ), ProtocolMessage(role="bot", sender=Sender(), content="Hello, world!"), ] assert ( basic_bot.make_prompt_author_role_alternated(mock_protocol_messages) == expected_protocol_messages ) class TestPoeBotWithAttachments: @pytest.mark.asyncio async def test_handle_query_attachment_bot_basic( self, attachment_bot: PoeBot, mock_request: QueryRequest, mock_request_context: RequestContext, ) -> None: expected_sse_events = [ ServerSentEvent( event="text", data=json.dumps({"text": "Generating... (1s elapsed)"}) ), ServerSentEvent( event="replace_response", data=json.dumps({"text": "Generating... (2s elapsed)"}), ), ServerSentEvent( event="replace_response", data=json.dumps({"text": "Generating... (3s elapsed)"}), ), ServerSentEvent( event="file", data=json.dumps( { "url": "https://pfst.cf2.poecdn.net/base/image/cat.jpg", "content_type": "image/jpeg", "name": "cat.jpg", "inline_ref": "abc", } ), ), ServerSentEvent( event="replace_response", data=json.dumps({"text": "![image][abc]"}) ), ServerSentEvent( event="file", data=json.dumps( { "url": "https://pfst.cf2.poecdn.net/base/application/test.pdf", "content_type": "application/pdf", "name": "test.pdf", "inline_ref": None, } ), ), ServerSentEvent(event="text", data=json.dumps({"text": ""})), ServerSentEvent(event="done", data="{}"), ] actual_sse_events = [ event async for event in attachment_bot.handle_query( mock_request, mock_request_context ) ] assert len(actual_sse_events) == len(expected_sse_events) for actual_event, expected_event in zip(actual_sse_events, expected_sse_events): assert actual_event.event == expected_event.event assert expected_event.data and actual_event.data assert json.loads(actual_event.data) == json.loads(expected_event.data) class TestPostMessageAttachment: @pytest.mark.asyncio @patch("httpx.AsyncClient.send") async def test_post_message_attachment_basic( self, mock_send: Mock, basic_bot: PoeBot ) -> None: mock_send.return_value = httpx.Response( 200, json={ "attachment_url": "https://pfst.cf2.poecdn.net/base/text/test.txt", "mime_type": "text/plain", }, ) result = await basic_bot.post_message_attachment( message_id="123", download_url="https://pfst.cf2.poecdn.net/base/text/test.txt", download_filename="test.txt", ) assert result == AttachmentUploadResponse( inline_ref=None, attachment_url="https://pfst.cf2.poecdn.net/base/text/test.txt", mime_type="text/plain", ) file_events_to_yield = basic_bot._file_events_to_yield.get("123", []) assert len(file_events_to_yield) == 1 assert file_events_to_yield.pop().data == json.dumps( { "url": "https://pfst.cf2.poecdn.net/base/text/test.txt", "content_type": "text/plain", "name": "test.txt", "inline_ref": None, } ) @pytest.mark.asyncio @patch("httpx.AsyncClient.send") async def test_post_message_attachment_download_url( self, mock_send: Mock, basic_bot: PoeBot ) -> None: mock_send.return_value = httpx.Response( 200, json={ "attachment_url": "https://pfst.cf2.poecdn.net/base/text/test.txt", "mime_type": "text/plain", }, ) result = await basic_bot.post_message_attachment( message_id="123", download_url="https://pfst.cf2.poecdn.net/base/text/test.txt", ) assert result == AttachmentUploadResponse( inline_ref=None, attachment_url="https://pfst.cf2.poecdn.net/base/text/test.txt", mime_type="text/plain", ) file_events_to_yield = basic_bot._file_events_to_yield.get("123", []) assert len(file_events_to_yield) == 1 assert file_events_to_yield.pop().data == json.dumps( { "url": "https://pfst.cf2.poecdn.net/base/text/test.txt", "content_type": "text/plain", "name": "test.txt", # extracted from url "inline_ref": None, } ) @pytest.mark.asyncio @patch("httpx.AsyncClient.send") @patch("fastapi_poe.base.generate_inline_ref") async def test_post_message_attachment_inline( self, mock_generate_inline_ref: Mock, mock_send: Mock, basic_bot: PoeBot ) -> None: mock_send.return_value = httpx.Response( 200, json={ "attachment_url": "https://pfst.cf2.poecdn.net/base/text/test.txt", "mime_type": "text/plain", }, ) mock_generate_inline_ref.return_value = "ab32ef21" result = await basic_bot.post_message_attachment( message_id="123", download_url="https://pfst.cf2.poecdn.net/base/text/test.txt", download_filename="test.txt", is_inline=True, ) assert result == AttachmentUploadResponse( inline_ref="ab32ef21", attachment_url="https://pfst.cf2.poecdn.net/base/text/test.txt", mime_type="text/plain", ) # Add a second attachment mock_send.return_value = httpx.Response( 200, json={ "attachment_url": "https://pfst.cf2.poecdn.net/base/image/test.png", "mime_type": "image/png", }, ) result = await basic_bot.post_message_attachment( message_id="123", download_url="https://pfst.cf2.poecdn.net/base/image/test.png", download_filename="test.png", is_inline=False, ) assert result == AttachmentUploadResponse( inline_ref=None, attachment_url="https://pfst.cf2.poecdn.net/base/image/test.png", mime_type="image/png", ) # Check that the file events are added to the instance dictionary file_events_to_yield = basic_bot._file_events_to_yield.get("123", []) assert len(file_events_to_yield) == 2 expected_items = [ { "url": "https://pfst.cf2.poecdn.net/base/text/test.txt", "content_type": "text/plain", "name": "test.txt", "inline_ref": "ab32ef21", }, { "url": "https://pfst.cf2.poecdn.net/base/image/test.png", "content_type": "image/png", "name": "test.png", "inline_ref": None, }, ] expected_items_json = {json.dumps(item) for item in expected_items} actual_items_json = {file_event.data for file_event in file_events_to_yield} assert expected_items_json == actual_items_json @pytest.mark.asyncio @patch("httpx.AsyncClient.send") async def test_post_message_attachment_error( self, mock_send: Mock, basic_bot: PoeBot ) -> None: mock_send.return_value = httpx.Response(400, json={"error": "test"}) with pytest.raises(AttachmentUploadError): await basic_bot.post_message_attachment( message_id="123", download_url="https://pfst.cf2.poecdn.net/base/text/test.txt", download_filename="test.txt", ) with pytest.raises(ValueError): await basic_bot.post_message_attachment( message_id="123", download_url="https://pfst.cf2.poecdn.net/base/text/test.txt", download_filename="test.txt", file_data=b"test", filename="test.txt", ) class TestCostCapture: def create_sse_mock( self, events: list[ServerSentEvent], status_code: int = 200, reason_phrase: str = "OK", ) -> Callable[..., AbstractAsyncContextManager[AsyncMock]]: @asynccontextmanager async def mock_sse_connection( *args: Any, **kwargs: Any # noqa: ANN401 ) -> AsyncIterator[AsyncMock]: mock_source = AsyncMock() mock_source.response.status_code = status_code mock_source.response.reason_phrase = reason_phrase async def mock_aiter_sse() -> AsyncIterator[ServerSentEvent]: for event in events: yield event mock_source.aiter_sse = mock_aiter_sse yield mock_source return mock_sse_connection @pytest.mark.asyncio async def test_authorize_cost_success( self, basic_bot: PoeBot, mock_request: QueryRequest ) -> None: cost_item = CostItem(amount_usd_milli_cents=1000) url = "https://example.com" events = [ServerSentEvent(event="result", data='{"status": "success"}')] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock( events, status_code=200, reason_phrase="OK" ) await basic_bot.authorize_cost( request=mock_request, amounts=cost_item, base_url=url ) mock_connect_sse.assert_called_once() call_args = mock_connect_sse.call_args assert ( call_args.kwargs["url"] == f"{url}bot/cost/{mock_request.bot_query_id}/authorize" ) assert call_args.kwargs["json"]["amounts"] == [cost_item.model_dump()] assert call_args.kwargs["json"]["access_key"] == basic_bot.access_key @pytest.mark.asyncio async def test_authorize_cost_failure( self, basic_bot: PoeBot, mock_request: QueryRequest ) -> None: cost_item = CostItem(amount_usd_milli_cents=1000) url = "https://example.com" events = [ ServerSentEvent(event="result", data='{"status": "insufficient funds"}') ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock( events, status_code=400, reason_phrase="Bad Request" ) with pytest.raises(CostRequestError): await basic_bot.authorize_cost( request=mock_request, amounts=cost_item, base_url=url ) with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock( events, status_code=200, reason_phrase="OK" ) with pytest.raises(InsufficientFundError): await basic_bot.authorize_cost( request=mock_request, amounts=cost_item, base_url=url ) @pytest.mark.asyncio async def test_capture_cost_success( self, basic_bot: PoeBot, mock_request: QueryRequest ) -> None: cost_item = CostItem(amount_usd_milli_cents=1000) url = "https://example.com" events = [ServerSentEvent(event="result", data='{"status": "success"}')] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock( events, status_code=200, reason_phrase="OK" ) await basic_bot.capture_cost( request=mock_request, amounts=cost_item, base_url=url ) mock_connect_sse.assert_called_once() call_args = mock_connect_sse.call_args assert ( call_args.kwargs["url"] == f"{url}bot/cost/{mock_request.bot_query_id}/capture" ) assert call_args.kwargs["json"]["amounts"] == [cost_item.model_dump()] assert call_args.kwargs["json"]["access_key"] == basic_bot.access_key @pytest.mark.asyncio async def test_capture_cost_failure( self, basic_bot: PoeBot, mock_request: QueryRequest ) -> None: cost_item = CostItem(amount_usd_milli_cents=1000) url = "https://example.com" events = [ ServerSentEvent(event="result", data='{"status": "insufficient funds"}') ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock( events, status_code=400, reason_phrase="Bad Request" ) with pytest.raises(CostRequestError): await basic_bot.capture_cost( request=mock_request, amounts=cost_item, base_url=url ) with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock( events, status_code=200, reason_phrase="OK" ) with pytest.raises(InsufficientFundError): await basic_bot.capture_cost( request=mock_request, amounts=cost_item, base_url=url ) def test_make_app(basic_bot: PoeBot, error_bot: PoeBot) -> None: app = make_app([basic_bot, error_bot]) assert app is not None assert app.router is not None expected_routes = [ {"path": "/bot/error_bot", "name": "poe_post", "methods": {"POST"}}, {"path": "/bot/test_bot", "name": "poe_post", "methods": {"POST"}}, ] routes = [route for route in app.router.routes if isinstance(route, Route)] for expected in expected_routes: route_exists = any( route.path == expected["path"] and route.name == expected["name"] and route.methods == expected["methods"] for route in routes ) assert route_exists, f"Route not found: {expected}" ================================================ FILE: tests/test_client.py ================================================ import json from collections.abc import AsyncGenerator, AsyncIterator, Awaitable from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Callable, cast from unittest.mock import ANY, AsyncMock, Mock, patch import httpx import pytest import pytest_asyncio from fastapi_poe.client import ( AttachmentUploadError, BotError, BotErrorNoRetry, _BotContext, _safe_ellipsis, get_bot_response, get_bot_response_sync, get_final_response, stream_request, sync_bot_settings, upload_file, ) from fastapi_poe.types import ( FunctionCallDefinition, ProtocolMessage, QueryRequest, Sender, ToolCallDefinition, ToolDefinition, ToolResultDefinition, ) from fastapi_poe.types import MetaResponse as MetaMessage from fastapi_poe.types import PartialResponse as BotMessage from sse_starlette import ServerSentEvent @pytest.fixture def mock_request() -> QueryRequest: return QueryRequest( version="1.2", type="query", query=[ProtocolMessage(role="user", content="Hello, world!", sender=Sender())], user_id="123", conversation_id="456", message_id="789", ) async def message_generator() -> AsyncGenerator[BotMessage, None]: return_messages = ["Hello,", " world", "!"] for message in return_messages: yield BotMessage(text=message) @pytest_asyncio.fixture async def mock_text_only_query_response() -> AsyncGenerator: yield message_generator() @pytest.mark.asyncio class TestStreamRequest: @pytest.fixture def tool_definitions_and_executables( self, ) -> tuple[list[ToolDefinition], list[Callable]]: def get_current_weather(location: str, unit: str = "fahrenheit") -> str: """Get the current weather in a given location""" if "tokyo" in location.lower(): return json.dumps( {"location": "Tokyo", "temperature": "11", "unit": unit} ) elif "san francisco" in location.lower(): return json.dumps( {"location": "San Francisco", "temperature": "72", "unit": unit} ) elif "paris" in location.lower(): return json.dumps( {"location": "Paris", "temperature": "22", "unit": unit} ) else: return json.dumps({"location": location, "temperature": "unknown"}) def get_current_mayor(location: str) -> str: """Get the current mayor of a given location.""" if "tokyo" in location.lower(): return json.dumps({"location": "Tokyo", "mayor": "Yuriko Koike"}) elif "san francisco" in location.lower(): return json.dumps( {"location": "San Francisco", "mayor": "London Breed"} ) elif "paris" in location.lower(): return json.dumps({"location": "Paris", "mayor": "Anne Hidalgo"}) else: return json.dumps({"location": location, "mayor": "unknown"}) mock_tool_dict_list = [ { "type": "function", "function": { "name": "get_current_weather", "description": "Get the current weather in a given location", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA", }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], }, }, "required": ["location"], }, }, }, { "type": "function", "function": { "name": "get_current_mayor", "description": "Get the current mayor of a given location.", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA", } }, "required": ["location"], }, }, }, ] tools = [ToolDefinition(**tool_dict) for tool_dict in mock_tool_dict_list] tool_executables = [get_current_weather, get_current_mayor] return tools, tool_executables def _create_mock_openai_response(self, delta: dict[str, Any]) -> dict[str, Any]: mock_tool_response_template = { "id": "chatcmpl-abcde", "object": "chat.completion.chunk", "created": 1738799163, "model": "gpt-3.5-turbo-0125", "service_tier": "default", "system_fingerprint": None, "choices": [ { "index": 0, "delta": { "role": "assistant", "content": None, "tool_calls": None, "refusal": None, }, "logprobs": None, "finish_reason": None, } ], "usage": None, } mock_tool_response_template["choices"][0]["delta"] = delta return mock_tool_response_template async def mock_perform_query_request_for_tools( self, ) -> AsyncGenerator[BotMessage, None]: """Mock the OpenAI API response for tool calls.""" # See https://platform.openai.com/docs/guides/function-calling?api-mode=chat#streaming # for details on OpenAI's streaming tool call format. mock_delta = [ { "tool_calls": [ { "index": 0, "id": "call_123", "type": "function", "function": {"name": "get_current_weather", "arguments": ""}, } ] }, {"tool_calls": [{"index": 0, "function": {"arguments": '{"'}}]}, { "tool_calls": [ { "index": 0, "function": {"arguments": 'location":"San Francisco, CA'}, } ] }, {"tool_calls": [{"index": 0, "function": {"arguments": '"}'}}]}, { "tool_calls": [ { "index": 1, "id": "call_456", "type": "function", "function": {"name": "get_current_mayor", "arguments": ""}, } ] }, {"tool_calls": [{"index": 1, "function": {"arguments": '{"'}}]}, { "tool_calls": [ {"index": 1, "function": {"arguments": 'location":"Tokyo, JP'}} ] }, {"tool_calls": [{"index": 1, "function": {"arguments": '"}'}}]}, {}, ] mock_responses = [ self._create_mock_openai_response(delta) for delta in mock_delta ] # last chunk has finish reason "tool_calls" mock_responses[-1]["choices"][0]["finish_reason"] = "tool_calls" return_values = [ BotMessage(text="", data=response) for response in mock_responses ] for message in return_values: yield message async def mock_perform_query_request_for_tools_missing_first_delta_for_index( self, ) -> AsyncGenerator[BotMessage, None]: """Mock the OpenAI API response for tool calls.""" # See https://platform.openai.com/docs/guides/function-calling?api-mode=chat#streaming # for details on OpenAI's streaming tool call format. mock_delta = [ # Missing the first delta for index 0, where the tool call id, type, and function name # are expected. {"tool_calls": [{"index": 0, "function": {"arguments": '{"'}}]}, { "tool_calls": [ { "index": 0, "function": {"arguments": 'location":"San Francisco, CA'}, } ] }, {"tool_calls": [{"index": 0, "function": {"arguments": '"}'}}]}, { "tool_calls": [ { "index": 1, "id": "call_456", "type": "function", "function": {"name": "get_current_mayor", "arguments": ""}, } ] }, {"tool_calls": [{"index": 1, "function": {"arguments": '{"'}}]}, { "tool_calls": [ {"index": 1, "function": {"arguments": 'location":"Tokyo, JP'}} ] }, {"tool_calls": [{"index": 1, "function": {"arguments": '"}'}}]}, {}, ] mock_responses = [ self._create_mock_openai_response(delta) for delta in mock_delta ] # last chunk has finish reason "tool_calls" mock_responses[-1]["choices"][0]["finish_reason"] = "tool_calls" return_values = [ BotMessage(text="", data=response) for response in mock_responses ] for message in return_values: yield message async def mock_perform_query_request_with_no_tools_selected( self, ) -> AsyncGenerator[BotMessage, None]: """Mock the OpenAI API response for tool calls when no tools are selected.""" mock_deltas = [ {"content": "there were"}, {"content": " no tool calls"}, {"content": "!"}, {}, ] mock_responses = [ self._create_mock_openai_response(delta) for delta in mock_deltas ] # last chunk has no choices array because it sends usage mock_responses[-1]["choices"] = [] mock_responses[-1]["usage"] = {"completion_tokens": 1, "prompt_tokens": 1} return_values = [ BotMessage(text="", data=response) for response in mock_responses ] for message in return_values: yield message async def mock_perform_query_request_with_bot_returning_regular_response( self, ) -> AsyncGenerator[BotMessage, None]: """Mock the OpenAI API response for tool calls when the bot returns a regular response.""" yield BotMessage(text="here ") yield BotMessage(text="is ") yield BotMessage(text="the ") yield BotMessage(text="final ") yield BotMessage(text="response") @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_basic( self, mock_perform_query_request: Mock, mock_request: QueryRequest, mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request.return_value = mock_text_only_query_response concatenated_text = "" async for message in stream_request(mock_request, "test_bot"): concatenated_text += message.text assert concatenated_text == "Hello, world!" @patch("fastapi_poe.client._BotContext") async def test_stream_request_with_extra_headers( self, mock_bot_context: Mock, mock_request: QueryRequest ) -> None: async for _ in stream_request( mock_request, "test_bot", extra_headers={"X-Test": "test"} ): pass mock_bot_context.assert_called_once_with( endpoint=ANY, session=ANY, api_key=ANY, on_error=ANY, extra_headers={"X-Test": "test"}, ) @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request_with_tools.side_effect = [ self.mock_perform_query_request_for_tools(), mock_text_only_query_response, ] tools, _ = tool_definitions_and_executables aggregated_tool_calls: dict[int, ToolCallDefinition] = {} async for message in stream_request(mock_request, "test_bot", tools=tools): if message.tool_calls: for tool_call in message.tool_calls: # Use the index to aggregate the tool call chunks if tool_call.index not in aggregated_tool_calls: aggregated_tool_calls[tool_call.index] = ToolCallDefinition( id=tool_call.id or "", type=tool_call.type or "", function=FunctionCallDefinition( name=tool_call.function.name or "", arguments=tool_call.function.arguments, ), ) else: aggregated_tool_calls[ tool_call.index ].function.arguments += tool_call.function.arguments expected_tool_calls = [ ToolCallDefinition( id="call_123", type="function", function=FunctionCallDefinition( name="get_current_weather", arguments='{"location":"San Francisco, CA"}', ), ), ToolCallDefinition( id="call_456", type="function", function=FunctionCallDefinition( name="get_current_mayor", arguments='{"location":"Tokyo, JP"}' ), ), ] assert list(aggregated_tool_calls.values()) == expected_tool_calls @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools_when_no_tools_selected( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], ) -> None: """Test case where the model does not select any tools to call.""" mock_perform_query_request_with_tools.side_effect = [ self.mock_perform_query_request_with_no_tools_selected() ] concatenated_text = "" tools, _ = tool_definitions_and_executables async for message in stream_request(mock_request, "test_bot", tools=tools): concatenated_text += message.text assert concatenated_text == "there were no tool calls!" # we should not make a second request if no tools are selected assert mock_perform_query_request_with_tools.call_count == 1 @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools_with_bot_returning_regular_response( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], ) -> None: """Test case where the model does not select any tools to call.""" mock_perform_query_request_with_tools.side_effect = [ self.mock_perform_query_request_with_bot_returning_regular_response() ] concatenated_text = "" tools, _ = tool_definitions_and_executables async for message in stream_request(mock_request, "test_bot", tools=tools): concatenated_text += message.text assert concatenated_text == "here is the final response" # we should not make a second request if no tools are selected assert mock_perform_query_request_with_tools.call_count == 1 @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools_and_tool_executables( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request_with_tools.side_effect = [ self.mock_perform_query_request_for_tools(), mock_text_only_query_response, ] concatenated_text = "" tools, tool_executables = tool_definitions_and_executables async for message in stream_request( mock_request, "test_bot", tools=tools, tool_executables=tool_executables ): concatenated_text += message.text assert concatenated_text == "Hello, world!" expected_tool_calls = [ ToolCallDefinition( id="call_123", type="function", function=FunctionCallDefinition( name="get_current_weather", arguments='{"location":"San Francisco, CA"}', ), ), ToolCallDefinition( id="call_456", type="function", function=FunctionCallDefinition( name="get_current_mayor", arguments='{"location":"Tokyo, JP"}' ), ), ] expected_tool_results = [ ToolResultDefinition( role="tool", name="get_current_weather", tool_call_id="call_123", content=json.dumps( tool_executables[0]('{"location":"San Francisco, CA"}') ), ), ToolResultDefinition( role="tool", name="get_current_mayor", tool_call_id="call_456", content=json.dumps(tool_executables[1]('{"location":"Tokyo, JP"}')), ), ] # check that the tool calls and results are passed to the second perform_query_request assert { "tool_calls": expected_tool_calls, "tool_results": expected_tool_results, }.items() <= mock_perform_query_request_with_tools.call_args_list[ 1 ].kwargs.items() @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools_and_tool_executables_missing_first_delta_for_index( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request_with_tools.side_effect = [ self.mock_perform_query_request_for_tools_missing_first_delta_for_index(), mock_text_only_query_response, ] concatenated_text = "" tools, tool_executables = tool_definitions_and_executables async for message in stream_request( mock_request, "test_bot", tools=tools, tool_executables=tool_executables ): concatenated_text += message.text assert concatenated_text == "Hello, world!" # The first delta for index 0 is missing, so we should only have one tool call (index 1). expected_tool_calls = [ ToolCallDefinition( id="call_456", type="function", function=FunctionCallDefinition( name="get_current_mayor", arguments='{"location":"Tokyo, JP"}' ), ) ] expected_tool_results = [ ToolResultDefinition( role="tool", name="get_current_mayor", tool_call_id="call_456", content=json.dumps(tool_executables[1]('{"location":"Tokyo, JP"}')), ) ] # check that the tool calls and results are passed to the second perform_query_request assert { "tool_calls": expected_tool_calls, "tool_results": expected_tool_results, }.items() <= mock_perform_query_request_with_tools.call_args_list[ 1 ].kwargs.items() @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools_and_tool_executables_when_no_tools_selected( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], ) -> None: """Test case where the model does not select any tools to call.""" mock_perform_query_request_with_tools.side_effect = [ self.mock_perform_query_request_with_no_tools_selected() ] concatenated_text = "" tools, tool_executables = tool_definitions_and_executables async for message in stream_request( mock_request, "test_bot", tools=tools, tool_executables=tool_executables ): concatenated_text += message.text assert concatenated_text == "there were no tool calls!" # we should not make a second request if no tools are selected assert mock_perform_query_request_with_tools.call_count == 1 @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_stream_request_with_tools_index_preserved( self, mock_perform_query_request_with_tools: Mock, mock_request: QueryRequest, tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]], ) -> None: """Test that index is preserved when yielding tool call responses.""" async def mock_response_with_index() -> AsyncGenerator[BotMessage, None]: mock_delta = { "tool_calls": [ { "index": 0, "id": "call_123", "type": "function", "function": {"name": "get_current_weather", "arguments": ""}, } ] } mock_response = self._create_mock_openai_response(mock_delta) message_with_index = BotMessage(text="", data=mock_response, index=1) yield message_with_index mock_response["choices"][0]["finish_reason"] = "tool_calls" yield BotMessage(text="", data=mock_response, index=1) mock_perform_query_request_with_tools.side_effect = [mock_response_with_index()] tools, _ = tool_definitions_and_executables messages_with_tool_calls = [] async for message in stream_request(mock_request, "test_bot", tools=tools): if message.tool_calls: messages_with_tool_calls.append(message) assert len(messages_with_tool_calls) > 0 # The index should be preserved from the incoming message # If the incoming message has index=1, the tool call message should also have index=1 assert ( messages_with_tool_calls[0].index == 1 ), f"Expected index=1, got {messages_with_tool_calls[0].index}" @pytest.mark.asyncio class Test_BotContext: @pytest.fixture def mock_bot_context(self) -> _BotContext: return _BotContext( endpoint="test_endpoint", session=AsyncMock(), api_key="test_api_key", on_error=Mock(), ) def create_sse_mock( self, events: list[ServerSentEvent] ) -> Callable[..., AbstractAsyncContextManager[AsyncMock]]: async def mock_sse_connection( *args: Any, **kwargs: Any # noqa: ANN401 ) -> AsyncIterator[AsyncMock]: mock_source = AsyncMock() async def mock_aiter_sse() -> AsyncIterator[ServerSentEvent]: for event in events: yield event mock_source.aiter_sse = mock_aiter_sse yield mock_source return asynccontextmanager(mock_sse_connection) def test_headers_include_accept_header_by_default(self) -> None: assert _BotContext(endpoint="test_endpoint", session=AsyncMock()).headers == { "Accept": "application/json" } def test_headers_include_api_key_as_auth_header(self) -> None: assert _BotContext( endpoint="test_endpoint", session=AsyncMock(), api_key="test_api_key" ).headers == { "Accept": "application/json", "Authorization": "Bearer test_api_key", } def test_headers_include_extra_headers(self) -> None: bot_context = _BotContext( endpoint="test_endpoint", session=AsyncMock(), api_key="test_api_key", extra_headers={"X-Test": "test"}, ) assert bot_context.headers == { "Accept": "application/json", "Authorization": "Bearer test_api_key", "X-Test": "test", } def test_headers_extra_headers_override_default_headers(self) -> None: bot_context = _BotContext( endpoint="test_endpoint", session=AsyncMock(), api_key="test_api_key", extra_headers={"Accept": "application/xml"}, ) assert bot_context.headers == { "Accept": "application/xml", "Authorization": "Bearer test_api_key", } async def test_perform_query_request_text_events( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: events = [ ServerSentEvent(event="text", data='{"text": "some"}'), ServerSentEvent(event="text", data='{"text": " response."}'), ServerSentEvent(event="done", data="{}"), ServerSentEvent( event="text", data='{"text": "blahblah"}' ), # after done; ignored ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) concatenated_text = "" async for message in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): concatenated_text += message.text assert concatenated_text == "some response." async def test_perform_query_request_non_text_events( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: # other events events = [ ServerSentEvent( event="meta", data=( '{"suggested_replies": true, ' '"content_type": "text/markdown", ' '"linkify": true}' ), ), ServerSentEvent(event="text", data='{"text": "some"}'), ServerSentEvent( event="meta", data='{"suggested_replies": true}' ), # non-first meta event ignored ServerSentEvent(event="replace_response", data='{"text": " response."}'), ServerSentEvent( event="suggested_reply", data='{"text": "what do you mean?"}' ), ServerSentEvent(event="json", data='{"fruit": "apple"}'), ServerSentEvent(event="ping", data="{}"), ServerSentEvent(event="done", data="{}"), ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) messages = [] async for message in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): messages.append(message) assert messages == [ MetaMessage( text="", raw_response={ "suggested_replies": True, "content_type": "text/markdown", "linkify": True, }, full_prompt=repr(mock_request), linkify=True, suggested_replies=True, content_type="text/markdown", ), BotMessage( text="some", raw_response={"type": "text", "text": '{"text": "some"}'}, full_prompt=repr(mock_request), is_replace_response=False, ), BotMessage( text=" response.", raw_response={ "type": "replace_response", "text": '{"text": " response."}', }, full_prompt=repr(mock_request), is_replace_response=True, ), BotMessage( text="what do you mean?", raw_response={ "type": "suggested_reply", "text": '{"text": "what do you mean?"}', }, full_prompt=repr(mock_request), is_suggested_reply=True, ), BotMessage( text="", full_prompt=repr(mock_request), data={"fruit": "apple"} ), ] async def test_perform_query_request_no_done_event_still_succeeds( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: events = [ ServerSentEvent(event="text", data='{"text": "some"}'), ServerSentEvent(event="text", data='{"text": " response."}'), ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) concatenated_text = "" async for message in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): concatenated_text += message.text assert concatenated_text == "some response." async def test_perform_query_request_error_with_allow_retry_false( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: events = [ ServerSentEvent(event="text", data='{"text": "some"}'), ServerSentEvent(event="error", data='{"allow_retry": false}'), ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) with pytest.raises(BotErrorNoRetry): async for _ in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): pass async def test_perform_query_request_error_with_allow_retry_true( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: events = [ ServerSentEvent(event="text", data='{"text": "some"}'), ServerSentEvent(event="error", data='{"allow_retry": true}'), ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) with pytest.raises(BotError): async for _ in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): pass async def test_perform_query_request_text_events_with_index( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: events = [ ServerSentEvent(event="text", data='{"text": "hello", "index": 0}'), ServerSentEvent(event="text", data='{"text": " world", "index": 0}'), ServerSentEvent(event="text", data='{"text": "hi.", "index": 1}'), # Bad index value should be ignored ServerSentEvent( event="text", data='{"text": "text with bad index", "index": "banana"}' ), ServerSentEvent(event="done", data="{}"), ServerSentEvent( event="text", data='{"text": "blahblah", "index": 2}' ), # after done; ignored ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) messages = [] async for message in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): messages.append(message) assert messages == [ BotMessage( text="hello", raw_response={ "type": "text", "text": '{"text": "hello", "index": 0}', }, full_prompt=repr(mock_request), is_replace_response=False, index=0, ), BotMessage( text=" world", raw_response={ "type": "text", "text": '{"text": " world", "index": 0}', }, full_prompt=repr(mock_request), is_replace_response=False, index=0, ), BotMessage( text="hi.", raw_response={ "type": "text", "text": '{"text": "hi.", "index": 1}', }, full_prompt=repr(mock_request), is_replace_response=False, index=1, ), BotMessage( text="text with bad index", raw_response={ "type": "text", "text": '{"text": "text with bad index", "index": "banana"}', }, full_prompt=repr(mock_request), is_replace_response=False, index=None, ), ] async def test_perform_query_request_non_text_events_with_index( self, mock_bot_context: _BotContext, mock_request: QueryRequest ) -> None: # other events events = [ ServerSentEvent(event="text", data='{"text": "first", "index": 0}'), ServerSentEvent( event="text", data='{"text": "second message", "index": 1}' ), ServerSentEvent( event="replace_response", data='{"text": " message.", "index": 0}' ), ServerSentEvent( event="suggested_reply", data='{"text": "what do you mean?", "index": 1}', ), ServerSentEvent(event="json", data='{"fruit": "apple", "index": 1}'), ServerSentEvent(event="ping", data='{"index": 1}'), ServerSentEvent(event="done", data='{"index": 1}'), ] with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) messages = [] async for message in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): messages.append(message) assert messages == [ BotMessage( text="first", raw_response={ "type": "text", "text": '{"text": "first", "index": 0}', }, full_prompt=repr(mock_request), is_replace_response=False, index=0, ), BotMessage( text="second message", raw_response={ "type": "text", "text": '{"text": "second message", "index": 1}', }, full_prompt=repr(mock_request), is_replace_response=False, index=1, ), BotMessage( text=" message.", raw_response={ "type": "replace_response", "text": '{"text": " message.", "index": 0}', }, full_prompt=repr(mock_request), is_replace_response=True, index=0, ), BotMessage( text="what do you mean?", raw_response={ "type": "suggested_reply", "text": '{"text": "what do you mean?", "index": 1}', }, full_prompt=repr(mock_request), is_suggested_reply=True, index=1, ), BotMessage( text="", full_prompt=repr(mock_request), data={"fruit": "apple", "index": 1}, index=1, ), ] @pytest.mark.parametrize( "events", [ [ ServerSentEvent( event="meta", data='{"suggested_replies": "true"}' ), # not bool ServerSentEvent(event="done", data="{}"), ], [ ServerSentEvent(event="meta", data='{"linkify": "banana"}'), # not bool ServerSentEvent(event="done", data="{}"), ], [ ServerSentEvent(event="meta", data='{"content_type": 123}'), # not str ServerSentEvent(event="done", data="{}"), ], [ServerSentEvent(event="done", data="{}")], # no text in response [ ServerSentEvent(event="bad", data='{"text": "some"}'), # unknown event ServerSentEvent(event="done", data="{}"), ], [ ServerSentEvent(event="text", data='{"text": banana}'), # improper json ServerSentEvent(event="done", data="{}"), ], [ ServerSentEvent(event="text", data='{"text": 123}'), # not str ServerSentEvent(event="done", data="{}"), ], [ ServerSentEvent(event="meta", data="123"), # not dict ServerSentEvent(event="done", data="{}"), ], ], ) async def test_perform_query_request_with_error( self, mock_bot_context: _BotContext, mock_request: QueryRequest, events: list[ServerSentEvent], ) -> None: with patch("httpx_sse.aconnect_sse") as mock_connect_sse: mock_connect_sse.side_effect = self.create_sse_mock(events) try: async for _ in mock_bot_context.perform_query_request( request=mock_request, tools=None, tool_calls=None, tool_results=None ): pass cast(Mock, mock_bot_context.on_error).assert_called_once() except Exception: pass @pytest.mark.asyncio @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_get_final_response( mock_perform_query_request: Mock, mock_request: QueryRequest, mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request.return_value = mock_text_only_query_response final_response = await get_final_response(mock_request, "test_bot") assert final_response == "Hello, world!" @pytest.mark.asyncio @patch("fastapi_poe.client._BotContext.perform_query_request") async def test_get_bot_response( mock_perform_query_request: Mock, mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request.return_value = mock_text_only_query_response mock_protocol_messages = [ ProtocolMessage(role="user", content="Hello, world!", sender=Sender()) ] concatenated_text = "" async for message in get_bot_response( mock_protocol_messages, "test_bot", api_key="test_api_key", temperature=0.5, skip_system_prompt=True, logit_bias={}, stop_sequences=["foo"], ): concatenated_text += message.text assert concatenated_text == "Hello, world!" @patch("fastapi_poe.client._BotContext.perform_query_request") def test_get_bot_response_sync( mock_perform_query_request: Mock, mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: mock_perform_query_request.return_value = mock_text_only_query_response mock_protocol_messages = [ ProtocolMessage(role="user", content="Hello, world!", sender=Sender()) ] concatenated_text = "" for message in get_bot_response_sync( mock_protocol_messages, "test_bot", api_key="test_api_key", temperature=0.5, skip_system_prompt=True, logit_bias={}, stop_sequences=["foo"], ): concatenated_text += message.text assert concatenated_text == "Hello, world!" @patch("fastapi_poe.client._BotContext.perform_query_request") @pytest.mark.asyncio async def test_get_bot_response_with_adopt_current_bot_name( mock_perform_query_request: Mock, mock_text_only_query_response: AsyncGenerator[BotMessage, None], ) -> None: """Test that adopt_current_bot_name parameter works with get_bot_response.""" mock_perform_query_request.return_value = mock_text_only_query_response messages = [ProtocolMessage(role="user", content="Hello, world!")] concatenated_text = "" async for message in get_bot_response( messages, "test_bot", api_key="test_api_key", adopt_current_bot_name=True ): concatenated_text += message.text assert concatenated_text == "Hello, world!" mock_perform_query_request.assert_called_once() @pytest.mark.parametrize( "test_input, limit, expected", [ ("hello world", 5, "he..."), ("test", 10, "test"), (123, 5, "123"), ([1, 2, 3], 7, "[1, ..."), (None, 6, "None"), ("", 5, ""), ], ) def test__safe_ellipsis(test_input: object, limit: int, expected: str) -> None: result = _safe_ellipsis(test_input, limit) assert result == expected @patch("httpx.post") def test_sync_bot_settings(mock_httpx_post: Mock) -> None: mock_httpx_post.return_value = Mock(status_code=200, text="{}") sync_bot_settings("test_bot", access_key="test_access_key", settings={"foo": "bar"}) mock_httpx_post.assert_called_once_with( "https://api.poe.com/bot/update_settings/test_bot/test_access_key/1.2", json={"foo": "bar"}, headers={"Content-Type": "application/json"}, ) mock_httpx_post.reset_mock() sync_bot_settings("test_bot", access_key="test_access_key") mock_httpx_post.assert_called_once_with( "https://api.poe.com/bot/fetch_settings/test_bot/test_access_key/1.2", # TODO: pass headers? # headers={"Content-Type": "application/json"}, ) mock_httpx_post.reset_mock() mock_httpx_post.return_value = Mock(status_code=500, text="{}") with pytest.raises(BotError): sync_bot_settings("test_bot", access_key="test_access_key") mock_httpx_post.side_effect = httpx.ReadTimeout("timeout") with pytest.raises(BotError): sync_bot_settings("test_bot", access_key="test_access_key") def _make_mock_async_client( fake_send: Callable[[httpx.Request], Awaitable[httpx.Response]] ) -> httpx.AsyncClient: """ Builds an `httpx.AsyncClient` double whose `send` coroutine is supplied by the caller (`fake_send`). """ client = AsyncMock(spec=httpx.AsyncClient) client.__aenter__.return_value = client client.__aexit__.return_value = None client.build_request = Mock( side_effect=lambda *args, **kwargs: httpx.Request(*args, **kwargs) ) client.send = AsyncMock(side_effect=fake_send) return client @pytest.mark.asyncio async def test_upload_file_via_url() -> None: expected_json = { "attachment_url": "https://cdn.example.com/fake-id/file.txt", "mime_type": "text/plain", } async def fake_send(request: httpx.Request) -> httpx.Response: return httpx.Response( status_code=200, content=json.dumps(expected_json).encode(), headers={"content-type": "application/json"}, ) mock_client = _make_mock_async_client(fake_send) with patch("httpx.AsyncClient", return_value=mock_client): attachment = await upload_file( file_url="https://example.com/file.txt", file_name="file.txt", api_key="secret-key", ) # Attachment object assert attachment.url == expected_json["attachment_url"] assert attachment.content_type == expected_json["mime_type"] assert attachment.name == "file.txt" # HTTP request send_mock: AsyncMock = cast(AsyncMock, mock_client.send) # satisfy pyright req: httpx.Request = send_mock.call_args.args[0] assert req.url.path.endswith("/file_upload_3RD_PARTY_POST") assert req.method == "POST" assert req.headers["Authorization"] == "secret-key" @pytest.mark.asyncio async def test_upload_file_raw_bytes() -> None: expected_json = { "attachment_url": "https://cdn.example.com/fake-id/hello.txt", "mime_type": "text/plain", } async def fake_send(request: httpx.Request) -> httpx.Response: return httpx.Response( status_code=200, content=json.dumps(expected_json).encode(), headers={"content-type": "application/json"}, ) mock_client = _make_mock_async_client(fake_send) with patch("httpx.AsyncClient", return_value=mock_client): attachment = await upload_file( file=b"hello world", file_name="hello.txt", api_key="secret-key" ) # Attachment object assert attachment.url == expected_json["attachment_url"] assert attachment.content_type == expected_json["mime_type"] assert attachment.name == "hello.txt" # HTTP request send_mock: AsyncMock = cast(AsyncMock, mock_client.send) # satisfy pyright req: httpx.Request = send_mock.call_args.args[0] assert req.headers["Authorization"] == "secret-key" assert req.headers["Content-Type"].startswith("multipart/form-data") @pytest.mark.asyncio async def test_upload_file_error_raises() -> None: async def fake_send(_: httpx.Request) -> httpx.Response: return httpx.Response(status_code=500, content=b"internal error") with ( patch("httpx.AsyncClient", return_value=_make_mock_async_client(fake_send)), pytest.raises(AttachmentUploadError), ): await upload_file(file_url="https://example.com/file.txt", api_key="secret-key") @pytest.mark.asyncio async def test_upload_file_with_extra_headers() -> None: expected_json = { "attachment_url": "https://cdn.example.com/fake-id/file.txt", "mime_type": "text/plain", } async def fake_send(request: httpx.Request) -> httpx.Response: return httpx.Response( status_code=200, content=json.dumps(expected_json).encode(), headers={"content-type": "application/json"}, ) mock_client = _make_mock_async_client(fake_send) with patch("httpx.AsyncClient", return_value=mock_client): attachment = await upload_file( file_url="https://example.com/file.txt", file_name="file.txt", api_key="secret-key", extra_headers={"X-Custom-Header": "custom-value"}, ) # Attachment object assert attachment.url == expected_json["attachment_url"] assert attachment.content_type == expected_json["mime_type"] assert attachment.name == "file.txt" # HTTP request should include both auth and custom headers send_mock: AsyncMock = cast(AsyncMock, mock_client.send) req: httpx.Request = send_mock.call_args.args[0] assert req.headers["Authorization"] == "secret-key" assert req.headers["X-Custom-Header"] == "custom-value" ================================================ FILE: tests/test_sync_utils.py ================================================ import asyncio import pytest from fastapi_poe.sync_utils import run_sync async def _add(a: int, b: int) -> int: await asyncio.sleep(0.01) return a + b def test_run_sync_without_event_loop() -> None: assert run_sync(_add(1, 2)) == 3 @pytest.mark.asyncio async def test_run_sync_inside_event_loop() -> None: assert run_sync(_add(4, 5)) == 9 @pytest.mark.asyncio async def test_run_sync_rejects_session_inside_loop() -> None: with pytest.raises(ValueError): run_sync(_add(1, 1), session="dummy") def test_run_sync_propagates_exceptions() -> None: async def _boom() -> None: raise RuntimeError("kaboom") with pytest.raises(RuntimeError, match="kaboom"): run_sync(_boom()) ================================================ FILE: tests/test_types.py ================================================ import pydantic import pytest from fastapi_poe.types import ( CostItem, CustomCallDefinition, CustomToolDefinition, MessageReaction, PartialResponse, ProtocolMessage, QueryRequest, Sender, SettingsResponse, User, ) class TestSettingsResponse: def test_default_response_version(self) -> None: response = SettingsResponse() assert response.response_version == 2 def test_extra_attrs() -> None: with pytest.raises(pydantic.ValidationError): PartialResponse(text="hi", replaceResponse=True) # type: ignore resp = PartialResponse(text="a capybara", is_replace_response=True) assert resp.is_replace_response is True assert resp.text == "a capybara" def test_cost_item() -> None: with pytest.raises(pydantic.ValidationError): CostItem(amount_usd_milli_cents="1") # type: ignore item = CostItem(amount_usd_milli_cents=25) assert item.amount_usd_milli_cents == 25 assert item.description is None item = CostItem(amount_usd_milli_cents=25.5, description="Test") # type: ignore assert item.amount_usd_milli_cents == 26 assert item.description == "Test" class TestSender: def test_sender_basic(self) -> None: sender = Sender() assert sender.id is None assert sender.name is None def test_sender_with_id(self) -> None: sender = Sender(id="user123") assert sender.id == "user123" assert sender.name is None def test_sender_with_name(self) -> None: sender = Sender(name="TestBot") assert sender.id is None assert sender.name == "TestBot" def test_sender_with_all_fields(self) -> None: sender = Sender(id="bot456", name="MyBot") assert sender.id == "bot456" assert sender.name == "MyBot" class TestUser: def test_user_basic(self) -> None: user = User(id="user123") assert user.id == "user123" assert user.name is None def test_user_with_name(self) -> None: user = User(id="user456", name="Alice") assert user.id == "user456" assert user.name == "Alice" def test_user_requires_id(self) -> None: with pytest.raises(pydantic.ValidationError): User() # type: ignore class TestMessageReaction: def test_reaction_basic(self) -> None: reaction = MessageReaction(user_id="user123", reaction="like") assert reaction.user_id == "user123" assert reaction.reaction == "like" def test_reaction_requires_user_id(self) -> None: with pytest.raises(pydantic.ValidationError): MessageReaction(reaction="like") # type: ignore def test_reaction_requires_reaction(self) -> None: with pytest.raises(pydantic.ValidationError): MessageReaction(user_id="user123") # type: ignore class TestProtocolMessage: def test_protocol_message_basic(self) -> None: msg = ProtocolMessage(role="user", sender=Sender(), content="Hello, world!") assert msg.role == "user" assert isinstance(msg.sender, Sender) assert msg.content == "Hello, world!" assert msg.reactions == [] assert msg.referenced_message is None def test_protocol_message_with_reactions(self) -> None: msg = ProtocolMessage( role="user", sender=Sender(), content="Hello!", reactions=[ MessageReaction(user_id="user1", reaction="like"), MessageReaction(user_id="user2", reaction="dislike"), ], ) assert len(msg.reactions) == 2 assert msg.reactions[0].user_id == "user1" assert msg.reactions[0].reaction == "like" assert msg.reactions[1].user_id == "user2" assert msg.reactions[1].reaction == "dislike" def test_protocol_message_with_referenced_message(self) -> None: referenced_msg = ProtocolMessage( role="user", sender=Sender(), content="Original message", message_id="msg123", ) reply_msg = ProtocolMessage( role="bot", sender=Sender(), content="Reply to original", referenced_message=referenced_msg, ) assert reply_msg.referenced_message is not None assert reply_msg.referenced_message.content == "Original message" assert reply_msg.referenced_message.message_id == "msg123" def test_protocol_message_optional_sender(self) -> None: # Sender is now optional msg = ProtocolMessage(role="user", content="Hello") assert msg.role == "user" assert msg.sender is None assert msg.content == "Hello" def test_protocol_message_nested_referenced_message(self) -> None: # Test deeply nested referenced messages msg1 = ProtocolMessage( role="user", sender=Sender(), content="First message", message_id="msg1" ) msg2 = ProtocolMessage( role="bot", sender=Sender(), content="Second message", message_id="msg2", referenced_message=msg1, ) msg3 = ProtocolMessage( role="user", sender=Sender(), content="Third message", message_id="msg3", referenced_message=msg2, ) assert msg3.referenced_message is not None assert msg3.referenced_message.message_id == "msg2" assert msg3.referenced_message.referenced_message is not None assert msg3.referenced_message.referenced_message.message_id == "msg1" def test_protocol_message_with_sender_object(self) -> None: sender = Sender(id="user123", name="TestUser") msg = ProtocolMessage(role="user", sender=sender, content="Hello, world!") assert msg.role == "user" assert msg.sender == sender assert msg.sender is not None assert msg.sender.id == "user123" assert msg.sender.name == "TestUser" assert msg.content == "Hello, world!" class TestQueryRequest: def test_query_request_with_users(self) -> None: query_request = QueryRequest( version="1.0", type="query", query=[ProtocolMessage(role="user", sender=Sender(), content="Hello")], user_id="user123", conversation_id="conv456", message_id="msg789", users=[User(id="user1", name="Alice"), User(id="user2", name="Bob")], ) assert len(query_request.users) == 2 assert query_request.users[0].id == "user1" assert query_request.users[0].name == "Alice" assert query_request.users[1].id == "user2" assert query_request.users[1].name == "Bob" def test_query_request_empty_users(self) -> None: query_request = QueryRequest( version="1.0", type="query", query=[ProtocolMessage(role="user", sender=Sender(), content="Hello")], user_id="user123", conversation_id="conv456", message_id="msg789", ) assert query_request.users == [] def test_query_request_with_reactions_in_messages(self) -> None: query_request = QueryRequest( version="1.0", type="query", query=[ ProtocolMessage( role="user", sender=Sender(id="user1"), content="Hello", reactions=[MessageReaction(user_id="user2", reaction="like")], ) ], user_id="user123", conversation_id="conv456", message_id="msg789", ) assert len(query_request.query[0].reactions) == 1 assert query_request.query[0].reactions[0].reaction == "like" class TestCustomToolDefinition: def test_basic_instantiation(self) -> None: """Test creating CustomToolDefinition with alias 'format'""" tool = CustomToolDefinition( name="my_tool", description="A custom tool", format={"type": "object", "properties": {}}, ) assert tool.name == "my_tool" assert tool.description == "A custom tool" assert tool.format_ == {"type": "object", "properties": {}} def test_field_name_works_with_populate_by_name(self) -> None: """Test that 'format_' field name also works due to populate_by_name=True""" tool = CustomToolDefinition( name="my_tool", description="A custom tool", format_={"type": "string"}, # type: ignore ) assert tool.format_ == {"type": "string"} def test_requires_name_field(self) -> None: """Test that name field is required""" with pytest.raises(pydantic.ValidationError): CustomToolDefinition() # type: ignore def test_optional_fields(self) -> None: """Test that description and format are optional""" tool = CustomToolDefinition(name="my_tool") assert tool.name == "my_tool" assert tool.description is None assert tool.format_ is None def test_with_only_name_and_description(self) -> None: """Test with only name and description""" tool = CustomToolDefinition(name="tool", description="desc") assert tool.name == "tool" assert tool.description == "desc" assert tool.format_ is None def test_with_only_name_and_format(self) -> None: """Test with only name and format""" tool = CustomToolDefinition(name="tool", format={"type": "string"}) assert tool.name == "tool" assert tool.description is None assert tool.format_ == {"type": "string"} def test_serialization_uses_alias(self) -> None: """Test that serialization uses 'format' not 'format_'""" tool = CustomToolDefinition( name="my_tool", description="desc", format={"key": "value"} ) data = tool.model_dump(by_alias=True) assert "format" in data assert "format_" not in data assert data["format"] == {"key": "value"} def test_serialization_without_alias(self) -> None: """Test that serialization without by_alias uses 'format_'""" tool = CustomToolDefinition( name="my_tool", description="desc", format={"key": "value"} ) data = tool.model_dump(by_alias=False) assert "format_" in data assert "format" not in data assert data["format_"] == {"key": "value"} def test_json_serialization(self) -> None: """Test JSON serialization with alias""" tool = CustomToolDefinition( name="tool", description="desc", format={"nested": "data"} ) json_str = tool.model_dump_json(by_alias=True) assert '"format"' in json_str assert '"format_"' not in json_str def test_deserialization_from_json(self) -> None: """Test deserializing from JSON with alias""" json_data = { "name": "tool1", "description": "A tool", "format": {"type": "array"}, } tool = CustomToolDefinition(**json_data) assert tool.name == "tool1" assert tool.format_ == {"type": "array"} def test_invalid_type_for_format(self) -> None: """Test that format must be a dict""" with pytest.raises(pydantic.ValidationError): CustomToolDefinition(name="tool", description="desc", format="not a dict") # type: ignore class TestCustomCallDefinition: def test_basic_instantiation(self) -> None: """Test creating CustomCallDefinition with alias 'input'""" call = CustomCallDefinition(name="my_tool", input='{"arg": "value"}') assert call.name == "my_tool" assert call.input_ == '{"arg": "value"}' def test_field_name_works_with_populate_by_name(self) -> None: """Test that 'input_' field name also works due to populate_by_name=True""" call = CustomCallDefinition(name="my_tool", input_='{"data": 123}') # type: ignore assert call.input_ == '{"data": 123}' def test_requires_all_fields(self) -> None: """Test that all required fields are validated""" with pytest.raises(pydantic.ValidationError): CustomCallDefinition(name="my_tool") # type: ignore with pytest.raises(pydantic.ValidationError): CustomCallDefinition(input="data") # type: ignore def test_serialization_uses_alias(self) -> None: """Test that serialization uses 'input' not 'input_'""" call = CustomCallDefinition(name="tool1", input="test_input") data = call.model_dump(by_alias=True) assert "input" in data assert "input_" not in data assert data["input"] == "test_input" def test_serialization_without_alias(self) -> None: """Test that serialization without by_alias uses 'input_'""" call = CustomCallDefinition(name="tool1", input="test_input") data = call.model_dump(by_alias=False) assert "input_" in data assert "input" not in data assert data["input_"] == "test_input" def test_json_serialization(self) -> None: """Test JSON serialization with alias""" call = CustomCallDefinition(name="calculator", input='{"operation": "add"}') json_str = call.model_dump_json(by_alias=True) assert '"input"' in json_str assert '"input_"' not in json_str def test_deserialization_from_json(self) -> None: """Test deserializing from JSON with alias""" json_data = { "name": "calculator", "input": '{"operation": "add", "a": 1, "b": 2}', } call = CustomCallDefinition(**json_data) assert call.name == "calculator" assert call.input_ == '{"operation": "add", "a": 1, "b": 2}'