[
  {
    "path": ".flake8",
    "content": "[flake8]\nmax-line-length = 100\nextend-immutable-calls = Depends, fastapi.Depends, fastapi.params.Depends\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "# This file is used to automatically assign reviewers to PRs\n# For more information see: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners\n\n* @poe-platform/fastapi_poe_reviewers\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Description\n\nBrief description of changes\n\n## Changes Made\n\n- Detailed change 1\n- Detailed change 2\n\n## Testing Done\n\n- Test scenario 1\n- Test scenario 2\n\n## Version\n\n- Updated to version 0.0.49\n\n## Breaking Changes\n\n- List any breaking changes\n\n## Checklist\n\n- [ ] Tests added\n- [ ] Documentation updated\n- [ ] Version bumped\n- [ ] Changes tested locally\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\n\non: [push, pull_request]\n\njobs:\n  precommit:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Set up latest Python\n        uses: actions/setup-python@v4\n        with:\n          python-version: \"3.11\"\n\n      - name: Run pre-commit hooks\n        uses: pre-commit/action@v3.0.0\n\n  pyright:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Set up latest Python\n        uses: actions/setup-python@v4\n        with:\n          python-version: \"3.11\"\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          python -m pip install .[dev]\n\n      - uses: jakebailey/pyright-action@v1\n\n  tests:\n    name: unit tests\n    timeout-minutes: 10\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [\"ubuntu-latest\", \"macos-latest\", \"windows-latest\"]\n        python-version: [\"3.9\", \"3.10\", \"3.11\", \"3.12\"]\n      fail-fast: false\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-python@v4\n        with:\n          python-version: ${{ matrix.python-version }}\n          allow-prereleases: true\n          cache: pip\n          cache-dependency-path: pyproject.toml\n      - run: pip install -e \".[dev]\"\n      - run: pytest tests/\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "# Based on\n# https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/\n\nname: Publish Python distribution to PyPI\n\non: push\n\njobs:\n  build:\n    name: Build distribution\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python\n        uses: actions/setup-python@v4\n        with:\n          python-version: \"3.x\"\n      - name: Install pypa/build\n        run: python3 -m pip install --user build\n      - name: Build a binary wheel and a source tarball\n        run: python3 -m build\n      - name: Store the distribution packages\n        uses: actions/upload-artifact@v4\n        with:\n          name: python-package-distributions\n          path: dist/\n\n  publish-to-pypi:\n    name: >-\n      Publish Python distribution to PyPI\n    if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes\n    needs:\n      - build\n    runs-on: ubuntu-latest\n    environment:\n      name: pypi\n      url: https://pypi.org/p/fastapi-poe\n    permissions:\n      id-token: write # IMPORTANT: mandatory for trusted publishing\n\n    steps:\n      - name: Download all the dists\n        uses: actions/download-artifact@v4\n        with:\n          name: python-package-distributions\n          path: dist/\n      - name: Publish distribution to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n"
  },
  {
    "path": ".gitignore",
    "content": "__pycache__/\n.DS_Store\n.coverage\nvenv/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.2.2\n    hooks:\n      - id: ruff\n        args: [--fix]\n\n  - repo: https://github.com/psf/black\n    rev: 24.2.0\n    hooks:\n      - id: black\n        language_version: python3.11\n\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.4.0\n    hooks:\n      - id: end-of-file-fixer\n      - id: trailing-whitespace\n\n  - repo: https://github.com/pre-commit/mirrors-prettier\n    rev: v3.1.0\n    hooks:\n      - id: prettier\n"
  },
  {
    "path": ".prettierignore",
    "content": "docs/*.md\n"
  },
  {
    "path": ".prettierrc.yaml",
    "content": "proseWrap: always\nprintWidth: 88\nendOfLine: auto\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# General\n\n- All changes should be made through pull requests\n- Pull requests should only be merged once all checks pass\n- The repo uses Black for formatting Python code, Prettier for formatting Markdown,\n  Pyright for type-checking Python, and a few other tools\n- To generate reference documentation, follow the instructions in\n  docs/generate_api_reference.py\n- To run the CI checks locally:\n  - `pip install pre-commit`\n  - `pre-commit run --all` (or `pre-commit install` to install the pre-commit hook)\n\n# Releases\n\nTo release a new version of `fastapi_poe`, do the following:\n\n- Make a PR updating the version number in `pyproject.toml` (example:\n  https://github.com/poe-platform/fastapi_poe/pull/2)\n- Merge it once CI passes\n- Go to https://github.com/poe-platform/fastapi_poe/releases/new and make a new release\n  (note this link works only if you have commit access to this repository)\n- The tag should be of the form \"0.0.X\".\n- Fill in the release notes with some description of what changed since the last\n  release.\n- [GitHub Actions](https://github.com/poe-platform/fastapi_poe/actions) will generate\n  the release artefacts and upload them to PyPI\n- You can check [PyPI](https://pypi.org/project/fastapi-poe/) to verify that the release\n  went through.\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# fastapi_poe\n\nAn implementation of the\n[Poe protocol](https://creator.poe.com/docs/poe-protocol-specification) using FastAPI.\n\n### Installation\n\nInstall the package from PyPI:\n\n```bash\npip install fastapi-poe\n```\n\n### Write your own bot\n\nThis package can also be used as a base to write your own bot. You can inherit from\n`PoeBot` to make a bot:\n\n```python\nimport fastapi_poe as fp\n\nclass EchoBot(fp.PoeBot):\n    async def get_response(self, request: fp.QueryRequest):\n        last_message = request.query[-1].content\n        yield fp.PartialResponse(text=last_message)\n\nif __name__ == \"__main__\":\n    fp.run(EchoBot(), allow_without_key=True)\n```\n\nNow, run your bot using `python <filename.py>`.\n\n- In a different terminal, run [ngrok](https://ngrok.com/) to make it publicly\n  accessible.\n- Use the publicly accessible url to integrate your bot with\n  [Poe](https://poe.com/create_bot?server=1)\n\n### Enable authentication\n\nPoe servers send requests containing Authorization HTTP header in the format \"Bearer\n<access_key>\"; the access key is configured in the bot settings page.\n\nTo validate that the request is from the Poe servers, you can either set the environment\nvariable POE_ACCESS_KEY or pass the parameter access_key in the run function like:\n\n```python\nif __name__ == \"__main__\":\n    fp.run(EchoBot(), access_key=<key>)\n```\n\n## Samples\n\nCheck out our starter code\n[repository](https://github.com/poe-platform/server-bot-quick-start) for some examples\nyou can use to get started with bot development.\n"
  },
  {
    "path": "docs/api_reference.md",
    "content": "\n\nThe 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`.\n\n## `fp.PoeBot`\n\nThe class that you use to define your bot behavior. Once you define your PoeBot class, you\npass it to `make_app` to create a FastAPI app that serves your bot.\n\n#### Parameters:\n- `path` (`str = \"/\"`): This is the path at which your bot is served. By default, it's\nset to \"/\" but this is something you can adjust. This is especially useful if you want to serve\nmultiple bots from one server.\n- `access_key` (`Optional[str] = None`): This is the access key for your bot and when\nprovided is used to validate that the requests are coming from a trusted source. This access key\nshould be the same one that you provide when integrating your bot with Poe at:\nhttps://poe.com/create_bot?server=1. You can also set this to None but certain features like\nfile output that mandate an `access_key` will not be available for your bot.\n- `should_insert_attachment_messages` (`bool = True`): A flag to decide whether to parse out\ncontent from attachments and insert them as messages into the conversation. This is set to\n`True` by default and we recommend leaving on since it allows your bot to comprehend attachments\nuploaded by users by default.\n- `concat_attachments_to_message` (`bool = False`): **DEPRECATED**: Please set\n`should_insert_attachment_messages` instead.\n\n### `PoeBot.get_response`\n\nOverride this to define your bot's response given a user query.\n#### Parameters:\n- `request` (`QueryRequest`): an object representing the chat response request from Poe.\nThis will contain information about the chat state among other things.\n\n#### Returns:\n- `AsyncIterable[PartialResponse]`: objects representing your\nresponse to the Poe servers. This is what gets displayed to the user.\n\nExample usage:\n```python\nasync def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]:\n    last_message = request.query[-1].content\n    yield fp.PartialResponse(text=last_message)\n```\n\n### `PoeBot.get_response_with_context`\n\nA version of `get_response` that also includes the request context information. By\ndefault, this will call `get_response`.\n#### Parameters:\n- `request` (`QueryRequest`): an object representing the chat response request from Poe.\nThis will contain information about the chat state among other things.\n- `context` (`RequestContext`): an object representing the current HTTP request.\n\n#### Returns:\n- `AsyncIterable[Union[PartialResponse, ErrorResponse]]`: objects representing your\nresponse to the Poe servers. This is what gets displayed to the user.\n\n### `PoeBot.get_settings`\n\nOverride this to define your bot's settings.\n\n#### Parameters:\n- `setting` (`SettingsRequest`): An object representing the settings request.\n\n#### Returns:\n- `SettingsResponse`: An object representing the settings you want to use for your bot.\n\n### `PoeBot.get_settings_with_context`\n\nA version of `get_settings` that also includes the request context information. By\ndefault, this will call `get_settings`.\n\n#### Parameters:\n- `setting` (`SettingsRequest`): An object representing the settings request.\n- `context` (`RequestContext`): an object representing the current HTTP request.\n\n#### Returns:\n- `SettingsResponse`: An object representing the settings you want to use for your bot.\n\n### `PoeBot.on_feedback`\n\nOverride this to record feedback from the user.\n#### Parameters:\n- `feedback_request` (`ReportFeedbackRequest`): An object representing the Feedback request\nfrom Poe. This is sent out when a user provides feedback on a response on your bot.\n#### Returns: `None`\n\n### `PoeBot.on_feedback_with_context`\n\nA version of `on_feedback` that also includes the request context information. By\ndefault, this will call `on_feedback`.\n\n#### Parameters:\n- `feedback_request` (`ReportFeedbackRequest`): An object representing a feedback request\nfrom Poe. This is sent out when a user provides feedback on a response on your bot.\n- `context` (`RequestContext`): an object representing the current HTTP request.\n#### Returns: `None`\n\n### `PoeBot.on_reaction_with_context`\n\nOverride this to record a reaction from the user. This also includes the request context.\n\n#### Parameters:\n- `reaction_request` (`ReportReactionRequest`): An object representing a reaction request\nfrom Poe. This is sent out when a user provides reaction on a response on your bot.\n- `context` (`RequestContext`): an object representing the current HTTP request.\n#### Returns: `None`\n\n### `PoeBot.on_error`\n\nOverride this to record errors from the Poe server.\n#### Parameters:\n- `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.\nThis is sent out when the Poe server runs into an issue processing the response from your\nbot.\n#### Returns: `None`\n\n### `PoeBot.on_error_with_context`\n\nA version of `on_error` that also includes the request context information. By\ndefault, this will call `on_error`.\n\n#### Parameters:\n- `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.\nThis is sent out when the Poe server runs into an issue processing the response from your\nbot.\n- `context` (`RequestContext`): an object representing the current HTTP request.\n#### Returns: `None`\n\n### `PoeBot.post_message_attachment`\n\nUsed to output an attachment in your bot's response.\n\n#### Parameters:\n- `message_id` (`Identifier`): The message id associated with the current QueryRequest.\n- `download_url` (`Optional[str] = None`): A url to the file to be attached to the message.\n- `download_filename` (`Optional[str] = None`): A filename to be used when storing the\ndownloaded attachment. If not set, the filename from the `download_url` is used.\n- `file_data` (`Optional[Union[bytes, BinaryIO]] = None`): The contents of the file to be\nuploaded. This should be a bytes-like or file object.\n- `filename` (`Optional[str] = None`): The name of the file to be attached.\n- `access_key` (`str`): **DEPRECATED**: Please set the access_key when creating the Bot\nobject instead.\n#### Returns:\n- `AttachmentUploadResponse`\n\n**Note**: You need to provide either the `download_url` or both of `file_data` and\n`filename`.\n\n### `PoeBot.concat_attachment_content_to_message_body`\n\n**DEPRECATED**: This method is deprecated. Use `insert_attachment_messages` instead.\n\nConcatenate received attachment file content into the message body. This will be called\nby default if `concat_attachments_to_message` is set to `True` but can also be used\nmanually if needed.\n\n#### Parameters:\n- `query_request` (`QueryRequest`): the request object from Poe.\n#### Returns:\n- `QueryRequest`: the request object after the attachments are unpacked and added to the\nmessage body.\n\n### `PoeBot.insert_attachment_messages`\n\nInsert messages containing the contents of each user attachment right before the last user\nmessage. This ensures the bot can consider all relevant information when generating a\nresponse. This will be called by default if `should_insert_attachment_messages` is set to\n`True` but can also be used manually if needed.\n\n#### Parameters:\n- `query_request` (`QueryRequest`): the request object from Poe.\n#### Returns:\n- `QueryRequest`: the request object after the attachments are unpacked and added to the\nmessage body.\n\n### `PoeBot.make_prompt_author_role_alternated`\n\nConcatenate consecutive messages from the same author into a single message. This is useful\nfor LLMs that require role alternation between user and bot messages.\n\n#### Parameters:\n- `protocol_messages` (`Sequence[ProtocolMessage]`): the messages to make alternated.\n#### Returns:\n- `Sequence[ProtocolMessage]`: the modified messages.\n\n### `PoeBot.capture_cost`\n\nUsed to capture variable costs for monetized and eligible bot creators.\nVisit https://creator.poe.com/docs/creator-monetization for more information.\n\n#### Parameters:\n- `request` (`QueryRequest`): The currently handled QueryRequest object.\n- `amounts` (`Union[list[CostItem], CostItem]`): The to be captured amounts.\n\n#### Returns: `None`\n\n### `PoeBot.authorize_cost`\n\nUsed to authorize a cost for monetized and eligible bot creators.\nVisit https://creator.poe.com/docs/creator-monetization for more information.\n\n#### Parameters:\n- `request` (`QueryRequest`): The currently handled QueryRequest object.\n- `amounts` (`Union[list[CostItem], CostItem]`): The to be authorized amounts.\n\n#### Returns: `None`\n\n\n\n---\n\n## `fp.make_app`\n\nCreate an app object for your bot(s).\n\n#### Parameters:\n- `bot` (`Union[PoeBot, Sequence[PoeBot]]`): A bot object or a list of bot objects if you want\nto host multiple bots on one server.\n- `access_key` (`str = \"\"`): The access key to use.  If not provided, the server tries to\nread the POE_ACCESS_KEY environment variable. If that is not set, the server will\nrefuse to start, unless `allow_without_key` is True. If multiple bots are provided,\nthe access key must be provided as part of the bot object.\n- `bot_name` (`str = \"\"`): The name of the bot as it appears on poe.com.\n- `api_key` (`str = \"\"`): **DEPRECATED**: Please set the access_key when creating the Bot\nobject instead.\n- `allow_without_key` (`bool = False`): If True, the server will start even if no access\nkey is provided. Requests will not be checked against any key. If an access key is provided, it\nis still checked.\n- `app` (`Optional[FastAPI] = None`): A FastAPI app instance. If provided, the app will be\nconfigured with the provided bots, access keys, and other settings. If not provided, a new\nFastAPI application instance will be created and configured.\n#### Returns:\n- `FastAPI`: A FastAPI app configured to serve your bot when run.\n\n\n\n---\n\n## `fp.run`\n\nServe a poe bot using a FastAPI app. This function should be used when you are running the\nbot locally. The parameters are the same as they are for `make_app`.\n\n#### Returns: `None`\n\n\n\n---\n\n## `fp.stream_request`\n\nThe Entry point for the Bot Query API. This API allows you to use other bots on Poe for\ninference in response to a user message. For more details, checkout:\nhttps://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe\n\n#### Parameters:\n- `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object\nalso includes information needed to identify the user for compute point usage.\n- `bot_name` (`str`): The bot you want to invoke.\n- `api_key` (`str = \"\"`): Your Poe API key, available at poe.com/api_key. You will need\nthis in case you are trying to use this function from a script/shell. Note that if an `api_key`\nis provided, compute points will be charged on the account corresponding to the `api_key`.\n- tools: (`Optional[list[ToolDefinition]] = None`): A list of ToolDefinition objects describing\nthe functions you have. This is used for OpenAI function calling.\n- tool_executables: (`Optional[list[Callable]] = None`): A list of functions corresponding\nto the ToolDefinitions. This is used for OpenAI function calling. When this is set, the\nLLM-suggested tools will automatically run once, before passing the results back to the LLM for\na final response.\n\n\n\n---\n\n## `fp.get_bot_response`\n\nUse this function to invoke another Poe bot from your shell.\n\n#### Parameters:\n- `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.\n- `bot_name` (`str`): The bot that you want to invoke.\n- `api_key` (`str`): Your Poe API key. Available at [poe.com/api_key](https://poe.com/api_key)\n- `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects\ndescribing the functions you have. This is used for OpenAI function calling.\n- `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding\nto the ToolDefinitions. This is used for OpenAI function calling.\n- `temperature` (`Optional[float] = None`): The temperature to use for the bot.\n- `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.\n- `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.\n- `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.\n- `base_url` (`str = \"https://api.poe.com/bot/\"`): The base URL to use for the bot. This is\nmainly for internal testing and is not expected to be changed.\n- `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.\n\n\n\n---\n\n## `fp.get_bot_response_sync`\n\nThis function wraps the async generator `fp.get_bot_response` and returns\npartial responses synchronously.\n\nFor asynchronous streaming, or integration into an existing event loop, use\n`fp.get_bot_response` directly.\n\n#### Parameters:\n- `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.\n- `bot_name` (`str`): The bot that you want to invoke.\n- `api_key` (`str`): Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key)\n- `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects\ndescribing the functions you have. This is used for OpenAI function calling.\n- `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding\nto the ToolDefinitions. This is used for OpenAI function calling.\n- `temperature` (`Optional[float] = None`): The temperature to use for the bot.\n- `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.\n- `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.\n- `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.\n- `base_url` (`str = \"https://api.poe.com/bot/\"`): The base URL to use for the bot. This is\nmainly for internal testing and is not expected to be changed.\n- `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.\n\n\n\n---\n\n## `fp.get_final_response`\n\nA helper function for the bot query API that waits for all the tokens and concatenates the full\nresponse before returning.\n\n#### Parameters:\n- `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object\nalso includes information needed to identify the user for compute point usage.\n- `bot_name` (`str`): The bot you want to invoke.\n- `api_key` (`str = \"\"`): Your Poe API key, available at poe.com/api_key. You will need this in\ncase you are trying to use this function from a script/shell. Note that if an `api_key` is\nprovided, compute points will be charged on the account corresponding to the `api_key`.\n\n\n\n---\n\n## `fp.upload_file`\n\nUpload a file (raw bytes *or* via URL) to Poe and receive an Attachment\nobject that can be returned directly from a bot or stored for later use.\n\n#### Parameters:\n- `file` (`Optional[Union[bytes, BinaryIO]] = None`): The file to upload.\n- `file_url` (`Optional[str] = None`): The URL of the file to upload.\n- `file_name` (`Optional[str] = None`): The name of the file to upload. Required if\n`file` is provided as raw bytes.\n- `api_key` (`str = \"\"`): Your Poe API key, available at poe.com/api_key. This can\nalso be the `access_key` if called from a Poe server bot.\n\n#### Returns:\n- `Attachment`: An Attachment object representing the uploaded file.\n\n\n\n---\n\n## `fp.upload_file_sync`\n\nThis is a synchronous wrapper around the async `upload_file`.\n\n\n\n---\n\n## `fp.QueryRequest`\n\nRequest parameters for a query request.\n#### Fields:\n- `query` (`list[ProtocolMessage]`): list of message representing the current state of the chat.\n- `user_id` (`Identifier`): an anonymized identifier representing a user. This is persistent\nfor subsequent requests from that user.\n- `conversation_id` (`Identifier`): an identifier representing a chat. This is\npersistent for subsequent request for that chat.\n- `message_id` (`Identifier`): an identifier representing a message.\n- `access_key` (`str = \"<missing>\"`): contains the access key defined when you created your bot\non Poe.\n- `temperature` (`float | None = None`): Temperature input to be used for model inference.\n- `skip_system_prompt` (`bool = False`): Whether to use any system prompting or not.\n- `logit_bias` (`dict[str, float] = {}`)\n- `stop_sequences` (`list[str] = []`)\n- `language_code` (`str = \"en\"`): BCP 47 language code of the user's client.\n- `bot_query_id` (`str = \"\"`): an identifier representing a bot query.\n- `users` (`list[User] = []`): list of users in the chat.\n\n\n\n---\n\n## `fp.ProtocolMessage`\n\nA message as used in the Poe protocol.\n#### Fields:\n- `role` (`Literal[\"system\", \"user\", \"bot\", \"tool\"]`): Message sender role.\n- `message_type` (`Optional[MessageType] = None`): Type of the message.\n- `sender_id` (`Optional[str]`): Sender ID of the message. This is deprecated, use\n  `sender` instead.\n- `sender` (`Optional[Sender] = None`): Sender of the message.\n- `content` (`str`): Content of the message.\n- `parameters` (`dict[str, Any] = {}`): Parameters for the message.\n- `content_type` (`ContentType=\"text/markdown\"`): Content type of the message.\n- `timestamp` (`int = 0`): Timestamp of the message.\n- `message_id` (`str = \"\"`): Message ID for the message.\n- `feedback` (`list[MessageFeedback] = []`): Feedback for the message.\n- `attachments` (`list[Attachment] = []`): Attachments for the message.\n- `metadata` (`Optional[str] = None`): Metadata associated with the message.\n- `referenced_message` (`Optional[\"ProtocolMessage\"] = None`): Message referenced by\n  this message (if any).\n- `reactions` (`list[MessageReaction] = []`): Reactions to the message.\n\n\n\n---\n\n## `fp.Sender`\n\nSender of a message.\n#### Fields:\n- `id` (`Optional[Identifier] = None`): An anonymized identifier representing the sender.\n- `name` (`Optional[str] = None`): The name of the sender.\nIf sender is a bot, this will be the name of the bot.\nIf sender is a user, this will be the name of the user if user name is available for this chat.\nTypically, user name is only available in a chat of multiple users. Please note that a user\ncan change their name anytime and different users with different `id` can share the same name.\n\n\n\n---\n\n## `fp.User`\n\nUser in a chat.\n#### Fields:\n- `id` (`Identifier`): An anonymized identifier representing a user.\n- `name` (`Optional[str] = None`): The name of the user if user name is available for this chat.\nTypically, user name is only available in a chat of multiple users. Please note that a user\ncan change their name anytime and different users with different `id` can share the same name.\n\n\n\n---\n\n## `fp.MessageReaction`\n\nReaction to a message.\n#### Fields:\n- `user_id` (`Identifier`): An anonymized identifier representing the\nuser who reacted to the message.\n- `reaction` (`str`): The reaction to the message.\n\n\n\n---\n\n## `fp.PartialResponse`\n\nRepresentation of a (possibly partial) response from a bot. Yield this in\n`PoeBot.get_response` or `PoeBot.get_response_with_context` to communicate your response to Poe.\n\n#### Fields:\n- `text` (`str`): The actual text you want to display to the user. Note that this should solely\nbe the text in the next token since Poe will automatically concatenate all tokens before\ndisplaying the response to the user.\n- `data` (`Optional[dict[str, Any]]`): Used to send arbitrary json data to Poe. This is\ncurrently only used for OpenAI function calling.\n- `is_suggested_reply` (`bool = False`): Setting this to true will create a suggested reply with\nthe provided text value.\n- `is_replace_response` (`bool = False`): Setting this to true will clear out the previously\ndisplayed text to the user and replace it with the provided text value.\n\n\n\n---\n\n## `fp.ErrorResponse`\n\nSimilar to `PartialResponse`. Yield this to communicate errors from your bot.\n\n#### Fields:\n- `allow_retry` (`bool = True`): Whether or not to allow a user to retry on error.\n- `error_type` (`Optional[ErrorType] = None`): An enum indicating what error to display.\n\n\n\n---\n\n## `fp.MetaResponse`\n\nSimilar to `Partial Response`. Yield this to communicate `meta` events from server bots.\n\n#### Fields:\n- `suggested_replies` (`bool = False`): Whether or not to enable suggested replies.\n- `content_type` (`ContentType = \"text/markdown\"`): Used to describe the format of the response.\nThe currently supported values are `text/plain` and `text/markdown`.\n- `refetch_settings` (`bool = False`): Used to trigger a settings fetch request from Poe. A more\nrobust way to trigger this is documented at:\nhttps://creator.poe.com/docs/server-bots/updating-bot-settings\n\n\n\n---\n\n## `fp.DataResponse`\n\nA response that contains arbitrary data to attach to the bot response.\nThis data can be retrieved in later requests to the bot within the same chat.\nNote that only the final DataResponse object in the stream will be attached to the bot response.\n\n#### Fields:\n- `metadata` (`str`): String of data to attach to the bot response.\n\n\n\n---\n\n## `fp.AttachmentUploadResponse`\n\nThe result of a post_message_attachment request.\n#### Fields:\n- `attachment_url` (`Optional[str]`): The URL of the attachment.\n- `mime_type` (`Optional[str]`): The MIME type of the attachment.\n- `inline_ref` (`Optional[str]`): The inline reference of the attachment.\nif post_message_attachment is called with is_inline=False, this will be None.\n\n\n\n---\n\n## `fp.SettingsRequest`\n\nRequest parameters for a settings request. Currently, this contains no fields but this\nmight get updated in the future.\n\n\n\n---\n\n## `fp.SettingsResponse`\n\nAn object representing your bot's response to a settings object.\n#### Fields:\n- `response_version` (`int = 2`): Different Poe Protocol versions use different default settings\nvalues. When provided, Poe will use the default values for the specified response version.\nIf not provided, Poe will use the default values for response version 0.\n- `server_bot_dependencies` (`dict[str, int] = {}`): Information about other bots that your bot\nuses. This is used to facilitate the Bot Query API.\n- `allow_attachments` (`bool = True`): Whether to allow users to upload attachments to your\nbot.\n- `introduction_message` (`str = \"\"`): The introduction message to display to the users of your\nbot.\n- `expand_text_attachments` (`bool = True`): Whether to request parsed content/descriptions from\ntext attachments with the query request. This content is sent through the new parsed_content\nfield in the attachment dictionary. This change makes enabling file uploads much simpler.\n- `enable_image_comprehension` (`bool = False`): Similar to `expand_text_attachments` but for\nimages.\n- `enforce_author_role_alternation` (`bool = False`): If enabled, Poe will concatenate messages\nso that they follow role alternation, which is a requirement for certain LLM providers like\nAnthropic.\n - `enable_multi_entity_prompting` (`bool = True`): If enabled, Poe will combine previous bot\n messages if there is a multientity context.\n- `parameter_controls` (`Optional[ParameterControls] = None`): Optional JSON object that defines\ninteractive parameter controls. The object must contain an api_version and sections array.\n\n\n\n---\n\n## `fp.ReportFeedbackRequest`\n\nRequest parameters for a report_feedback request.\n#### Fields:\n- `message_id` (`Identifier`)\n- `user_id` (`Identifier`)\n- `conversation_id` (`Identifier`)\n- `feedback_type` (`FeedbackType`)\n\n\n\n---\n\n## `fp.ReportReactionRequest`\n\nRequest parameters for a report_reaction request.\n#### Fields:\n- `message_id` (`Identifier`)\n- `user_id` (`Identifier`)\n- `conversation_id` (`Identifier`)\n- `reaction` (`str`)\n\n\n\n---\n\n## `fp.ReportErrorRequest`\n\nRequest parameters for a report_error request.\n#### Fields:\n- `message` (`str`)\n- `metadata` (`dict[str, Any]`)\n\n\n\n---\n\n## `fp.Attachment`\n\nAttachment included in a protocol message.\n#### Fields:\n- `url` (`str`): The download URL of the attachment.\n- `content_type` (`str`): The MIME type of the attachment.\n- `name` (`str`): The name of the attachment.\n- `inline_ref` (`Optional[str] = None`): Set this to make Poe render the attachment inline.\n    You can then reference the attachment inline using ![title][inline_ref].\n- `parsed_content` (`Optional[str] = None`): The parsed content of the attachment.\n\n\n\n---\n\n## `fp.MessageFeedback`\n\nFeedback for a message as used in the Poe protocol.\n#### Fields:\n- `type` (`FeedbackType`)\n- `reason` (`Optional[str]`)\n\n\n\n---\n\n## `fp.ToolDefinition`\n\nAn object representing a tool definition used for OpenAI function calling.\n#### Fields:\n- `type` (`str`)\n- `function` (`FunctionDefinition`): Look at the source code for a detailed description\nof what this means.\n\n\n\n---\n\n## `fp.ToolCallDefinition`\n\nAn object representing a tool call. This is returned as a response by the model when using\nOpenAI function calling.\n#### Fields:\n- `id` (`str`)\n- `type` (`str`)\n- `function` (`FunctionDefinition`): The function name (string) and arguments (JSON string).\n\n\n\n---\n\n## `fp.ToolResultDefinition`\n\nAn object representing a function result. This is passed to the model in the last step\nwhen using OpenAI function calling.\n#### Fields:\n- `role` (`str`)\n- `name` (`str`)\n- `tool_call_id` (`str`)\n- `content` (`str`)\n"
  },
  {
    "path": "docs/generate_api_reference.py",
    "content": "\"\"\"\n\n- To generate reference documentation:\n  - Add/update docstrings in the codebase. If you are adding a new class/function, add\n    it's name to `documented_items` in `docs/generate_api_reference.py`\n  - Install local version of fastapi_poe: `pip install -e .`\n  - run `python3 generate_api_reference.py`\n  - [Internal only] Copy the contents of `api_reference.md` to the reference page in\n    README.\n\n\"\"\"\n\nimport inspect\nimport sys\nimport types\nfrom dataclasses import dataclass, field\nfrom typing import Callable, Optional, Union\n\nsys.path.append(\"../src\")\nimport fastapi_poe\n\nINITIAL_TEXT = \"\"\"\n\nThe following is the API reference for the \\\n[fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes \\\nthat you used `import fastapi_poe as fp`.\n\n\"\"\"\n\n\n@dataclass\nclass DocumentationData:\n    name: str\n    docstring: Optional[str]\n    data_type: str\n    children: list = field(default_factory=lambda: [])\n\n\ndef _unwrap_func(func_obj: Union[staticmethod, Callable]) -> Callable:\n    \"\"\"Grab the underlying func_obj.\"\"\"\n    if isinstance(func_obj, staticmethod):\n        return _unwrap_func(func_obj.__func__)\n    return func_obj\n\n\ndef get_documentation_data(\n    *, module: types.ModuleType, documented_items: list[str]\n) -> dict[str, DocumentationData]:\n    data_dict = {}\n    for name, obj in inspect.getmembers(module):\n        if (\n            inspect.isclass(obj) or inspect.isfunction(obj)\n        ) and name in documented_items:\n            doc = inspect.getdoc(obj)\n            data_type = \"class\" if inspect.isclass(obj) else \"function\"\n            dd_obj = DocumentationData(name=name, docstring=doc, data_type=data_type)\n\n            if inspect.isclass(obj):\n                children = []\n                # for func_name, func_obj in inspect.getmembers(obj, inspect.isfunction):\n                for func_name, func_obj in obj.__dict__.items():\n                    if not inspect.isfunction(func_obj):\n                        continue\n                    if not func_name.startswith(\"_\"):\n                        func_obj = _unwrap_func(func_obj)\n                        func_doc = inspect.getdoc(func_obj)\n                        children.append(\n                            DocumentationData(\n                                name=func_name, docstring=func_doc, data_type=\"function\"\n                            )\n                        )\n                dd_obj.children = children\n            data_dict[name] = dd_obj\n    return data_dict\n\n\ndef generate_documentation(\n    *,\n    data_dict: dict[str, DocumentationData],\n    documented_items: list[str],\n    output_filename: str,\n) -> None:\n    # reset the file first\n    with open(output_filename, \"w\") as f:\n        f.write(\"\")\n\n    with open(output_filename, \"w\") as f:\n        f.write(INITIAL_TEXT)\n\n        first = True\n        for item in documented_items:\n            if first is True:\n                first = False\n            else:\n                f.write(\"---\\n\\n\")\n            item_data = data_dict[item]\n            f.write(f\"## `fp.{item_data.name}`\\n\\n\")\n            f.write(f\"{item_data.docstring}\\n\\n\")\n            for child in item_data.children:\n                if not child.docstring:\n                    continue\n                f.write(f\"### `{item}.{child.name}`\\n\\n\")\n                f.write(f\"{child.docstring}\\n\\n\")\n            f.write(\"\\n\\n\")\n\n\n# Specify the names of classes and functions to document\ndocumented_items = [\n    \"PoeBot\",\n    \"make_app\",\n    \"run\",\n    \"stream_request\",\n    \"get_bot_response\",\n    \"get_bot_response_sync\",\n    \"get_final_response\",\n    \"upload_file\",\n    \"upload_file_sync\",\n    \"QueryRequest\",\n    \"ProtocolMessage\",\n    \"Sender\",\n    \"User\",\n    \"MessageReaction\",\n    \"PartialResponse\",\n    \"ErrorResponse\",\n    \"MetaResponse\",\n    \"DataResponse\",\n    \"AttachmentUploadResponse\",\n    \"SettingsRequest\",\n    \"SettingsResponse\",\n    \"ReportFeedbackRequest\",\n    \"ReportReactionRequest\",\n    \"ReportErrorRequest\",\n    \"Attachment\",\n    \"MessageFeedback\",\n    \"ToolDefinition\",\n    \"ToolCallDefinition\",\n    \"ToolResultDefinition\",\n]\n\ndata_dict = get_documentation_data(\n    module=fastapi_poe, documented_items=documented_items\n)\noutput_filename = \"api_reference.md\"\ngenerate_documentation(\n    data_dict=data_dict,\n    documented_items=documented_items,\n    output_filename=output_filename,\n)\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"fastapi_poe\"\nversion = \"0.0.83\"\nauthors = [\n  { name=\"Yusheng Ding\", email=\"yding@quora.com\" },\n  { name=\"Kris Yang\", email=\"kryang@quora.com\" },\n  { name=\"John Li\", email=\"jli@quora.com\" },\n]\ndescription = \"A demonstration of the Poe protocol using FastAPI\"\nreadme = \"README.md\"\nrequires-python = \">=3.9\"\nclassifiers = [\n    \"Programming Language :: Python :: 3\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: OS Independent\",\n]\ndependencies = [\n    \"fastapi\",\n    \"sse-starlette>=2.2.1\",\n    \"typing-extensions>=4.5.0\",\n    \"uvicorn\",\n    \"httpx\",\n    \"httpx-sse\",\n    \"pydantic>2\",\n]\n[project.optional-dependencies]\ndev = [\n    \"pytest\",\n    \"pytest-cov\",\n    \"pytest-asyncio\",\n]\n\n[project.urls]\n\"Homepage\" = \"https://creator.poe.com/\"\n\n[tool.pyright]\npythonVersion = \"3.9\"\n\n[tool.pytest.ini_options]\naddopts = \"--cov=src/fastapi_poe --cov-fail-under=80\"\nrequired_plugins = [\"pytest-cov\"]\n\n[tool.black]\ntarget-version = ['py39']\nskip-magic-trailing-comma = true\n\n[tool.ruff]\nlint.select = [\n  \"F\",\n  \"E\",\n  \"I\",  # import sorting\n  \"ANN\",  # type annotations for everything\n  \"C4\",  # flake8-comprehensions\n  \"B\",  # bugbear\n  \"SIM\",  # simplify\n  \"UP\",  # pyupgrade\n  \"PIE810\",  # startswith/endswith with a tuple\n  \"SIM101\",  # mergeable isinstance() calls\n  \"SIM201\",  # \"not ... == ...\" -> \"... != ...\"\n  \"SIM202\",  # \"not ... != ...\" -> \"... == ...\"\n  \"C400\",  # unnecessary list() calls\n  \"C401\",  # unnecessary set() calls\n  \"C402\",  # unnecessary dict() calls\n  \"C403\",  # unnecessary listcomp within set() call\n  \"C404\",  # unnecessary listcomp within dict() call\n  \"C405\",  # use set literals\n  \"C406\",  # use dict literals\n  \"C409\",  # tuple() calls that can be replaced with literals\n  \"C410\",  # list() calls that can be replaced with literals\n  \"C411\",  # list() calls with genexps that can be replaced with listcomps\n  \"C413\",  # unnecessary list() calls around sorted()\n  \"C414\",  # unnecessary list() calls inside sorted()\n  \"C417\",  # unnecessary map() calls that can be replaced with listcomps/genexps\n  \"C418\",  # unnecessary dict() calls that can be replaced with literals\n  \"PERF101\",  # unnecessary list() calls that can be replaced with literals\n]\n\nlint.ignore = [\n  \"B008\",  # do not perform function calls in argument defaults\n  \"ANN101\",  # missing type annotation for self in method\n  \"ANN102\",  # missing type annotation for cls in classmethod\n]\n\nline-length = 100\ntarget-version = \"py39\"\n"
  },
  {
    "path": "src/fastapi_poe/__init__.py",
    "content": "__all__ = [\n    \"PoeBot\",\n    \"run\",\n    \"make_app\",\n    \"stream_request\",\n    \"get_bot_response\",\n    \"get_bot_response_sync\",\n    \"get_final_response\",\n    \"BotError\",\n    \"BotErrorNoRetry\",\n    \"Attachment\",\n    \"ProtocolMessage\",\n    \"Sender\",\n    \"User\",\n    \"MessageReaction\",\n    \"QueryRequest\",\n    \"SettingsRequest\",\n    \"ReportFeedbackRequest\",\n    \"ReportReactionRequest\",\n    \"ReportErrorRequest\",\n    \"SettingsResponse\",\n    \"PartialResponse\",\n    \"ErrorResponse\",\n    \"MetaResponse\",\n    \"DataResponse\",\n    \"AttachmentUploadResponse\",\n    \"RequestContext\",\n    \"ToolDefinition\",\n    \"ToolCallDefinition\",\n    \"ToolResultDefinition\",\n    \"MessageFeedback\",\n    \"sync_bot_settings\",\n    \"CostItem\",\n    \"InsufficientFundError\",\n    \"CostRequestError\",\n    \"upload_file\",\n    \"upload_file_sync\",\n    \"ParameterControls\",\n    \"Section\",\n    \"Tab\",\n    \"FullControls\",\n    \"ConditionallyRenderControls\",\n    \"ComparatorCondition\",\n    \"ParameterValue\",\n    \"LiteralValue\",\n    \"BaseControl\",\n    \"AspectRatio\",\n    \"AspectRatioOption\",\n    \"Slider\",\n    \"ToggleSwitch\",\n    \"DropDown\",\n    \"ValueNamePair\",\n    \"TextArea\",\n    \"TextField\",\n    \"Divider\",\n    \"Number\",\n]\n\nfrom .base import CostRequestError, InsufficientFundError, PoeBot, make_app, run\nfrom .client import (\n    BotError,\n    BotErrorNoRetry,\n    get_bot_response,\n    get_bot_response_sync,\n    get_final_response,\n    stream_request,\n    sync_bot_settings,\n    upload_file,\n    upload_file_sync,\n)\nfrom .types import (\n    AspectRatio,\n    AspectRatioOption,\n    Attachment,\n    AttachmentUploadResponse,\n    BaseControl,\n    ComparatorCondition,\n    ConditionallyRenderControls,\n    CostItem,\n    DataResponse,\n    Divider,\n    DropDown,\n    ErrorResponse,\n    FullControls,\n    LiteralValue,\n    MessageFeedback,\n    MessageReaction,\n    MetaResponse,\n    Number,\n    ParameterControls,\n    ParameterValue,\n    PartialResponse,\n    ProtocolMessage,\n    QueryRequest,\n    ReportErrorRequest,\n    ReportFeedbackRequest,\n    ReportReactionRequest,\n    RequestContext,\n    Section,\n    Sender,\n    SettingsRequest,\n    SettingsResponse,\n    Slider,\n    Tab,\n    TextArea,\n    TextField,\n    ToggleSwitch,\n    ToolCallDefinition,\n    ToolDefinition,\n    ToolResultDefinition,\n    User,\n    ValueNamePair,\n)\n"
  },
  {
    "path": "src/fastapi_poe/base.py",
    "content": "import argparse\nimport asyncio\nimport copy\nimport json\nimport logging\nimport os\nimport random\nimport string\nimport sys\nimport warnings\nfrom collections import defaultdict\nfrom collections.abc import AsyncIterable, Awaitable, Sequence\nfrom dataclasses import dataclass\nfrom typing import BinaryIO, Callable, Optional, Union\nfrom urllib.parse import unquote, urlparse\n\nimport httpx\nimport httpx_sse\nfrom fastapi import Depends, FastAPI, HTTPException, Request, Response\nfrom fastapi.exceptions import RequestValidationError\nfrom fastapi.responses import HTMLResponse, JSONResponse\nfrom fastapi.security import HTTPAuthorizationCredentials, HTTPBearer\nfrom sse_starlette.event import ServerSentEvent\nfrom sse_starlette.sse import EventSourceResponse\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.types import Message\nfrom typing_extensions import deprecated, overload\n\nfrom fastapi_poe.client import PROTOCOL_VERSION, sync_bot_settings, upload_file\nfrom fastapi_poe.templates import (\n    IMAGE_VISION_ATTACHMENT_TEMPLATE,\n    TEXT_ATTACHMENT_TEMPLATE,\n    URL_ATTACHMENT_TEMPLATE,\n)\nfrom fastapi_poe.types import (\n    Attachment,\n    AttachmentUploadResponse,\n    ContentType,\n    CostItem,\n    DataResponse,\n    ErrorResponse,\n    Identifier,\n    MetaResponse,\n    PartialResponse,\n    ProtocolMessage,\n    QueryRequest,\n    ReportErrorRequest,\n    ReportFeedbackRequest,\n    ReportReactionRequest,\n    RequestContext,\n    Sender,\n    SettingsRequest,\n    SettingsResponse,\n)\n\nlogger = logging.getLogger(\"uvicorn.default\")\nPOE_API_WEBSERVER_BASE_URL = \"https://www.quora.com/poe_api/\"\n\n\nclass InvalidParameterError(Exception):\n    pass\n\n\nclass CostRequestError(Exception):\n    pass\n\n\nclass InsufficientFundError(Exception):\n    pass\n\n\nclass LoggingMiddleware(BaseHTTPMiddleware):  # pragma: no cover\n    async def set_body(self, request: Request) -> None:\n        receive_ = await request._receive()\n\n        async def receive() -> Message:\n            return receive_\n\n        request._receive = receive\n\n    async def dispatch(\n        self, request: Request, call_next: Callable[[Request], Awaitable[Response]]\n    ) -> Response:\n        logger.info(f\"Request: {request.method} {request.url}\")\n        try:\n            # Per https://github.com/tiangolo/fastapi/issues/394#issuecomment-927272627\n            # to avoid blocking.\n            await self.set_body(request)\n            body = await request.json()\n            logger.debug(f\"Request body: {json.dumps(body)}\")\n        except json.JSONDecodeError:\n            logger.error(\"Request body: Unable to parse JSON\")\n\n        response = await call_next(request)\n\n        logger.info(f\"Response status: {response.status_code}\")\n        try:\n            if hasattr(response, \"body\"):\n                body = json.loads(bytes(response.body).decode())\n                logger.debug(f\"Response body: {json.dumps(body)}\")\n        except json.JSONDecodeError:\n            logger.error(\"Response body: Unable to parse JSON\")\n\n        return response\n\n\nasync def http_exception_handler(request: Request, ex: Exception) -> Response:\n    logger.error(ex)\n    return Response(status_code=500, content=\"Internal server error\")\n\n\nhttp_bearer = HTTPBearer()\n\n\ndef generate_inline_ref() -> str:\n    return \"\".join(random.choices(string.ascii_letters + string.digits, k=8))\n\n\ndef get_filename_from_url(url: str) -> str:\n    parsed_url = urlparse(url)\n    filename = os.path.basename(parsed_url.path)\n    filename = unquote(filename)\n    return filename or \"downloaded_file\"\n\n\n@dataclass\nclass PoeBot:\n    \"\"\"\n\n    The class that you use to define your bot behavior. Once you define your PoeBot class, you\n    pass it to `make_app` to create a FastAPI app that serves your bot.\n\n    #### Parameters:\n    - `path` (`str = \"/\"`): This is the path at which your bot is served. By default, it's\n    set to \"/\" but this is something you can adjust. This is especially useful if you want to serve\n    multiple bots from one server.\n    - `access_key` (`Optional[str] = None`): This is the access key for your bot and when\n    provided is used to validate that the requests are coming from a trusted source. This access key\n    should be the same one that you provide when integrating your bot with Poe at:\n    https://poe.com/create_bot?server=1. You can also set this to None but certain features like\n    file output that mandate an `access_key` will not be available for your bot.\n    - `should_insert_attachment_messages` (`bool = True`): A flag to decide whether to parse out\n    content from attachments and insert them as messages into the conversation. This is set to\n    `True` by default and we recommend leaving on since it allows your bot to comprehend attachments\n    uploaded by users by default.\n    - `concat_attachments_to_message` (`bool = False`): **DEPRECATED**: Please set\n    `should_insert_attachment_messages` instead.\n\n    \"\"\"\n\n    path: str = \"/\"  # Path where this bot will be exposed\n    access_key: Optional[str] = None  # Access key for this bot\n    bot_name: Optional[str] = None  # Name of the bot using this PoeBot instance in Poe\n    should_insert_attachment_messages: bool = (\n        True  # Whether to insert attachment messages into the conversation\n    )\n    concat_attachments_to_message: bool = False  # Deprecated\n\n    # Override these for your bot\n    async def get_response(\n        self, request: QueryRequest\n    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:\n        \"\"\"\n\n        Override this to define your bot's response given a user query.\n        #### Parameters:\n        - `request` (`QueryRequest`): an object representing the chat response request from Poe.\n        This will contain information about the chat state among other things.\n\n        #### Returns:\n        - `AsyncIterable[PartialResponse]`: objects representing your\n        response to the Poe servers. This is what gets displayed to the user.\n\n        Example usage:\n        ```python\n        async def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]:\n            last_message = request.query[-1].content\n            yield fp.PartialResponse(text=last_message)\n        ```\n\n        \"\"\"\n        yield self.text_event(\"hello\")\n\n    async def get_response_with_context(\n        self, request: QueryRequest, context: RequestContext\n    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:\n        \"\"\"\n\n        A version of `get_response` that also includes the request context information. By\n        default, this will call `get_response`.\n        #### Parameters:\n        - `request` (`QueryRequest`): an object representing the chat response request from Poe.\n        This will contain information about the chat state among other things.\n        - `context` (`RequestContext`): an object representing the current HTTP request.\n\n        #### Returns:\n        - `AsyncIterable[Union[PartialResponse, ErrorResponse]]`: objects representing your\n        response to the Poe servers. This is what gets displayed to the user.\n\n        \"\"\"\n        try:\n            async for event in self.get_response(request):\n                yield event\n        except InsufficientFundError:\n            yield ErrorResponse(error_type=\"insufficient_fund\", text=\"\")\n\n    async def get_settings(self, setting: SettingsRequest) -> SettingsResponse:\n        \"\"\"\n\n        Override this to define your bot's settings.\n\n        #### Parameters:\n        - `setting` (`SettingsRequest`): An object representing the settings request.\n\n        #### Returns:\n        - `SettingsResponse`: An object representing the settings you want to use for your bot.\n\n        \"\"\"\n        return SettingsResponse()\n\n    async def get_settings_with_context(\n        self, setting: SettingsRequest, context: RequestContext\n    ) -> SettingsResponse:\n        \"\"\"\n\n        A version of `get_settings` that also includes the request context information. By\n        default, this will call `get_settings`.\n\n        #### Parameters:\n        - `setting` (`SettingsRequest`): An object representing the settings request.\n        - `context` (`RequestContext`): an object representing the current HTTP request.\n\n        #### Returns:\n        - `SettingsResponse`: An object representing the settings you want to use for your bot.\n\n        \"\"\"\n        settings = await self.get_settings(setting)\n        return settings\n\n    async def on_feedback(self, feedback_request: ReportFeedbackRequest) -> None:\n        \"\"\"\n\n        Override this to record feedback from the user.\n        #### Parameters:\n        - `feedback_request` (`ReportFeedbackRequest`): An object representing the Feedback request\n        from Poe. This is sent out when a user provides feedback on a response on your bot.\n        #### Returns: `None`\n\n        \"\"\"\n        pass\n\n    async def on_feedback_with_context(\n        self, feedback_request: ReportFeedbackRequest, context: RequestContext\n    ) -> None:\n        \"\"\"\n\n        A version of `on_feedback` that also includes the request context information. By\n        default, this will call `on_feedback`.\n\n        #### Parameters:\n        - `feedback_request` (`ReportFeedbackRequest`): An object representing a feedback request\n        from Poe. This is sent out when a user provides feedback on a response on your bot.\n        - `context` (`RequestContext`): an object representing the current HTTP request.\n        #### Returns: `None`\n\n        \"\"\"\n        await self.on_feedback(feedback_request)\n\n    async def on_reaction_with_context(\n        self, reaction_request: ReportReactionRequest, context: RequestContext\n    ) -> None:\n        \"\"\"\n\n        Override this to record a reaction from the user. This also includes the request context.\n\n        #### Parameters:\n        - `reaction_request` (`ReportReactionRequest`): An object representing a reaction request\n        from Poe. This is sent out when a user provides reaction on a response on your bot.\n        - `context` (`RequestContext`): an object representing the current HTTP request.\n        #### Returns: `None`\n\n        \"\"\"\n        pass\n\n    async def on_error(self, error_request: ReportErrorRequest) -> None:\n        \"\"\"\n\n        Override this to record errors from the Poe server.\n        #### Parameters:\n        - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.\n        This is sent out when the Poe server runs into an issue processing the response from your\n        bot.\n        #### Returns: `None`\n\n        \"\"\"\n        logger.error(f\"Error from Poe server: {error_request}\")\n\n    async def on_error_with_context(\n        self, error_request: ReportErrorRequest, context: RequestContext\n    ) -> None:\n        \"\"\"\n\n        A version of `on_error` that also includes the request context information. By\n        default, this will call `on_error`.\n\n        #### Parameters:\n        - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.\n        This is sent out when the Poe server runs into an issue processing the response from your\n        bot.\n        - `context` (`RequestContext`): an object representing the current HTTP request.\n        #### Returns: `None`\n\n        \"\"\"\n        await self.on_error(error_request)\n\n    # Helpers for generating responses\n    def __post_init__(self) -> None:\n        self._file_events_to_yield: dict[Identifier, list[ServerSentEvent]] = {}\n\n    # This overload leaves access_key as the first argument, but is deprecated.\n    @overload\n    @deprecated(\n        \"The access_key and content_type parameters are deprecated. \"\n        \"Set the access_key when creating the Bot object instead.\"\n    )\n    async def post_message_attachment(\n        self,\n        access_key: str,\n        message_id: Identifier,\n        *,\n        download_url: Optional[str] = None,\n        download_filename: Optional[str] = None,\n        file_data: Optional[Union[bytes, BinaryIO]] = None,\n        filename: Optional[str] = None,\n        content_type: Optional[str] = None,\n        is_inline: bool = False,\n        base_url: str = POE_API_WEBSERVER_BASE_URL,\n    ) -> AttachmentUploadResponse: ...\n\n    # This overload requires all parameters to be passed as keywords\n    @overload\n    async def post_message_attachment(\n        self,\n        *,\n        message_id: Identifier,\n        download_url: Optional[str] = None,\n        download_filename: Optional[str] = None,\n        file_data: Optional[Union[bytes, BinaryIO]] = None,\n        filename: Optional[str] = None,\n        is_inline: bool = False,\n        base_url: str = POE_API_WEBSERVER_BASE_URL,\n    ) -> AttachmentUploadResponse: ...\n\n    async def post_message_attachment(\n        self,\n        access_key: Optional[str] = None,\n        message_id: Optional[Identifier] = None,\n        *,\n        download_url: Optional[str] = None,\n        download_filename: Optional[str] = None,\n        file_data: Optional[Union[bytes, BinaryIO]] = None,\n        filename: Optional[str] = None,\n        content_type: Optional[str] = None,\n        is_inline: bool = False,\n        base_url: str = POE_API_WEBSERVER_BASE_URL,\n    ) -> AttachmentUploadResponse:\n        \"\"\"\n\n        Used to output an attachment in your bot's response.\n\n        #### Parameters:\n        - `message_id` (`Identifier`): The message id associated with the current QueryRequest.\n        - `download_url` (`Optional[str] = None`): A url to the file to be attached to the message.\n        - `download_filename` (`Optional[str] = None`): A filename to be used when storing the\n        downloaded attachment. If not set, the filename from the `download_url` is used.\n        - `file_data` (`Optional[Union[bytes, BinaryIO]] = None`): The contents of the file to be\n        uploaded. This should be a bytes-like or file object.\n        - `filename` (`Optional[str] = None`): The name of the file to be attached.\n        - `access_key` (`str`): **DEPRECATED**: Please set the access_key when creating the Bot\n        object instead.\n        #### Returns:\n        - `AttachmentUploadResponse`\n\n        **Note**: You need to provide either the `download_url` or both of `file_data` and\n        `filename`.\n\n        \"\"\"\n\n        assert message_id is not None, \"message_id parameter is required\"\n        name = filename or download_filename\n        if not name:\n            if not download_url:\n                raise InvalidParameterError(\n                    \"filename or download_url/download_filename required\"\n                )\n            else:\n                name = get_filename_from_url(download_url)\n\n        if self.access_key:\n            if access_key:\n                warnings.warn(\n                    \"Bot already has an access key, access_key parameter is not needed.\",\n                    DeprecationWarning,\n                    stacklevel=2,\n                )\n                attachment_access_key = access_key\n            else:\n                attachment_access_key = self.access_key\n        else:\n            if access_key is None:\n                raise InvalidParameterError(\n                    \"access_key parameter is required if bot is not\"\n                    + \" provided with an access_key when make_app is called.\"\n                )\n            attachment_access_key = access_key\n\n        if content_type is not None:\n            warnings.warn(\n                \"content_type parameter is deprecated, and will be removed in a future release.\",\n                DeprecationWarning,\n                stacklevel=2,\n            )\n\n        attachment = await self._upload_file(\n            file=file_data,\n            file_url=download_url,\n            file_name=filename or download_filename,\n            api_key=attachment_access_key,\n            base_url=base_url,\n        )\n\n        inline_ref = generate_inline_ref() if is_inline else None\n        file_events_to_yield = self._file_events_to_yield.setdefault(message_id, [])\n\n        assert name is not None  # we check this above, but pyright can't detect it\n        file_events_to_yield.append(\n            self.file_event(\n                url=attachment.url,\n                content_type=attachment.content_type,\n                name=name,\n                inline_ref=inline_ref,\n            )\n        )\n        return AttachmentUploadResponse(\n            attachment_url=attachment.url,\n            mime_type=attachment.content_type,\n            inline_ref=inline_ref,\n        )\n\n    async def _upload_file(\n        self,\n        *,\n        file: Optional[Union[bytes, BinaryIO]],\n        file_url: Optional[str],\n        file_name: Optional[str],\n        api_key: str,\n        base_url: str,\n    ) -> Attachment:\n        return await upload_file(\n            file=file,\n            file_url=file_url,\n            file_name=file_name,\n            api_key=api_key,\n            base_url=base_url,\n        )\n\n    @deprecated(\n        \"This method is deprecated. Use `insert_attachment_messages` instead.\"\n        \"This method will be removed in a future release.\"\n    )\n    def concat_attachment_content_to_message_body(\n        self, query_request: QueryRequest\n    ) -> QueryRequest:  # pragma: no cover\n        \"\"\"\n\n        **DEPRECATED**: This method is deprecated. Use `insert_attachment_messages` instead.\n\n        Concatenate received attachment file content into the message body. This will be called\n        by default if `concat_attachments_to_message` is set to `True` but can also be used\n        manually if needed.\n\n        #### Parameters:\n        - `query_request` (`QueryRequest`): the request object from Poe.\n        #### Returns:\n        - `QueryRequest`: the request object after the attachments are unpacked and added to the\n        message body.\n\n        \"\"\"\n        last_message = query_request.query[-1]\n        concatenated_content = last_message.content\n        for attachment in last_message.attachments:\n            if attachment.parsed_content:\n                if attachment.content_type == \"text/html\":\n                    url_attachment_content = URL_ATTACHMENT_TEMPLATE.format(\n                        attachment_name=attachment.name,\n                        content=attachment.parsed_content,\n                    )\n                    concatenated_content = (\n                        f\"{concatenated_content}\\n\\n{url_attachment_content}\"\n                    )\n                elif \"text\" in attachment.content_type:\n                    text_attachment_content = TEXT_ATTACHMENT_TEMPLATE.format(\n                        attachment_name=attachment.name,\n                        attachment_parsed_content=attachment.parsed_content,\n                    )\n                    concatenated_content = (\n                        f\"{concatenated_content}\\n\\n{text_attachment_content}\"\n                    )\n                elif \"image\" in attachment.content_type:\n                    parsed_content_filename = attachment.parsed_content.split(\"***\")[0]\n                    parsed_content_text = attachment.parsed_content.split(\"***\")[1]\n                    image_attachment_content = IMAGE_VISION_ATTACHMENT_TEMPLATE.format(\n                        filename=parsed_content_filename,\n                        parsed_image_description=parsed_content_text,\n                    )\n                    concatenated_content = (\n                        f\"{concatenated_content}\\n\\n{image_attachment_content}\"\n                    )\n        modified_last_message = last_message.model_copy(\n            update={\"content\": concatenated_content}\n        )\n        modified_query = query_request.model_copy(\n            update={\"query\": query_request.query[:-1] + [modified_last_message]}\n        )\n        return modified_query\n\n    def insert_attachment_messages(self, query_request: QueryRequest) -> QueryRequest:\n        \"\"\"\n\n        Insert messages containing the contents of each user attachment right before the last user\n        message. This ensures the bot can consider all relevant information when generating a\n        response. This will be called by default if `should_insert_attachment_messages` is set to\n        `True` but can also be used manually if needed.\n\n        #### Parameters:\n        - `query_request` (`QueryRequest`): the request object from Poe.\n        #### Returns:\n        - `QueryRequest`: the request object after the attachments are unpacked and added to the\n        message body.\n\n        \"\"\"\n        last_message = query_request.query[-1]\n        text_attachment_messages = []\n        image_attachment_messages = []\n        for attachment in last_message.attachments:\n            if attachment.parsed_content:\n                if attachment.content_type == \"text/html\":\n                    url_attachment_content = URL_ATTACHMENT_TEMPLATE.format(\n                        attachment_name=attachment.name,\n                        content=attachment.parsed_content,\n                    )\n                    text_attachment_messages.append(\n                        ProtocolMessage(\n                            role=\"user\", sender=Sender(), content=url_attachment_content\n                        )\n                    )\n                elif (\n                    attachment.content_type.startswith(\"text/\")\n                    or attachment.content_type == \"application/pdf\"\n                ):\n                    text_attachment_content = TEXT_ATTACHMENT_TEMPLATE.format(\n                        attachment_name=attachment.name,\n                        attachment_parsed_content=attachment.parsed_content,\n                    )\n                    text_attachment_messages.append(\n                        ProtocolMessage(\n                            role=\"user\",\n                            sender=Sender(),\n                            content=text_attachment_content,\n                        )\n                    )\n                elif \"image\" in attachment.content_type:\n                    try:\n                        # Poe currently sends analysis in the format of filename***analysis\n                        parsed_content_filename, parsed_content_text = (\n                            attachment.parsed_content.split(\"***\", 1)\n                        )\n                    except ValueError:\n                        # If the format is not filename***analysis, use the attachment filename\n                        parsed_content_filename = attachment.name\n                        parsed_content_text = attachment.parsed_content\n                    image_attachment_content = IMAGE_VISION_ATTACHMENT_TEMPLATE.format(\n                        filename=parsed_content_filename,\n                        parsed_image_description=parsed_content_text,\n                    )\n                    image_attachment_messages.append(\n                        ProtocolMessage(\n                            role=\"user\",\n                            sender=Sender(),\n                            content=image_attachment_content,\n                        )\n                    )\n        modified_query = query_request.model_copy(\n            update={\n                \"query\": query_request.query[:-1]\n                + text_attachment_messages\n                + image_attachment_messages\n                + [last_message]\n            }\n        )\n        return modified_query\n\n    def make_prompt_author_role_alternated(\n        self, protocol_messages: Sequence[ProtocolMessage]\n    ) -> Sequence[ProtocolMessage]:\n        \"\"\"\n\n        Concatenate consecutive messages from the same author into a single message. This is useful\n        for LLMs that require role alternation between user and bot messages.\n\n        #### Parameters:\n        - `protocol_messages` (`Sequence[ProtocolMessage]`): the messages to make alternated.\n        #### Returns:\n        - `Sequence[ProtocolMessage]`: the modified messages.\n\n        \"\"\"\n        new_messages = []\n\n        for protocol_message in protocol_messages:\n            if new_messages and protocol_message.role == new_messages[-1].role:\n                prev_message = new_messages.pop()\n                new_content = prev_message.content + \"\\n\\n\" + protocol_message.content\n\n                new_attachments = []\n                added_attachment_urls = set()\n                for attachment in (\n                    protocol_message.attachments + prev_message.attachments\n                ):\n                    if attachment.url not in added_attachment_urls:\n                        added_attachment_urls.add(attachment.url)\n                        new_attachments.append(attachment)\n\n                new_messages.append(\n                    prev_message.model_copy(\n                        update={\"content\": new_content, \"attachments\": new_attachments}\n                    )\n                )\n            else:\n                new_messages.append(protocol_message)\n\n        return new_messages\n\n    async def capture_cost(\n        self,\n        request: QueryRequest,\n        amounts: Union[list[CostItem], CostItem],\n        base_url: str = \"https://api.poe.com/\",\n    ) -> None:\n        \"\"\"\n\n        Used to capture variable costs for monetized and eligible bot creators.\n        Visit https://creator.poe.com/docs/creator-monetization for more information.\n\n        #### Parameters:\n        - `request` (`QueryRequest`): The currently handled QueryRequest object.\n        - `amounts` (`Union[list[CostItem], CostItem]`): The to be captured amounts.\n\n        #### Returns: `None`\n\n        \"\"\"\n\n        if not self.access_key:\n            raise CostRequestError(\n                \"Please provide the bot access_key when make_app is called.\"\n            )\n\n        if not request.bot_query_id:\n            raise InvalidParameterError(\n                \"bot_query_id is required to make cost requests.\"\n            )\n\n        url = f\"{base_url}bot/cost/{request.bot_query_id}/capture\"\n        result = await self._cost_requests_inner(\n            amounts=amounts, access_key=self.access_key, url=url\n        )\n        if not result:\n            raise InsufficientFundError()\n\n    async def authorize_cost(\n        self,\n        request: QueryRequest,\n        amounts: Union[list[CostItem], CostItem],\n        base_url: str = \"https://api.poe.com/\",\n    ) -> None:\n        \"\"\"\n\n        Used to authorize a cost for monetized and eligible bot creators.\n        Visit https://creator.poe.com/docs/creator-monetization for more information.\n\n        #### Parameters:\n        - `request` (`QueryRequest`): The currently handled QueryRequest object.\n        - `amounts` (`Union[list[CostItem], CostItem]`): The to be authorized amounts.\n\n        #### Returns: `None`\n\n        \"\"\"\n\n        if not self.access_key:\n            raise CostRequestError(\n                \"Please provide the bot access_key when make_app is called.\"\n            )\n\n        if not request.bot_query_id:\n            raise InvalidParameterError(\n                \"bot_query_id is required to make cost requests.\"\n            )\n\n        url = f\"{base_url}bot/cost/{request.bot_query_id}/authorize\"\n        result = await self._cost_requests_inner(\n            amounts=amounts, access_key=self.access_key, url=url\n        )\n        if not result:\n            raise InsufficientFundError()\n\n    async def _cost_requests_inner(\n        self, amounts: Union[list[CostItem], CostItem], access_key: str, url: str\n    ) -> bool:\n        amounts = [amounts] if isinstance(amounts, CostItem) else amounts\n        amounts_dicts = [amount.model_dump() for amount in amounts]\n        data = {\"amounts\": amounts_dicts, \"access_key\": access_key}\n        try:\n            async with (\n                httpx.AsyncClient(timeout=300) as client,\n                httpx_sse.aconnect_sse(\n                    client, method=\"POST\", url=url, json=data\n                ) as event_source,\n            ):\n                if event_source.response.status_code != 200:\n                    error_pieces = [\n                        json.loads(event.data).get(\"message\", \"\")\n                        async for event in event_source.aiter_sse()\n                    ]\n                    raise CostRequestError(\n                        f\"{event_source.response.status_code} \"\n                        f\"{event_source.response.reason_phrase}: {''.join(error_pieces)}\"\n                    )\n\n                async for event in event_source.aiter_sse():\n                    if event.event == \"result\":\n                        event_data = json.loads(event.data)\n                        result = event_data[\"status\"]\n                        return result == \"success\"\n            return False\n        except httpx.HTTPError:\n            logger.error(\n                \"An HTTP error occurred when attempting to send a cost request.\"\n            )\n            raise\n\n    @staticmethod\n    def text_event(text: str) -> ServerSentEvent:\n        return ServerSentEvent(data=json.dumps({\"text\": text}), event=\"text\")\n\n    @staticmethod\n    def file_event(\n        url: str, content_type: str, name: str, inline_ref: Optional[str] = None\n    ) -> ServerSentEvent:\n        return ServerSentEvent(\n            data=json.dumps(\n                {\n                    \"url\": url,\n                    \"content_type\": content_type,\n                    \"name\": name,\n                    \"inline_ref\": inline_ref,\n                }\n            ),\n            event=\"file\",\n        )\n\n    @staticmethod\n    def data_event(metadata: str) -> ServerSentEvent:\n        return ServerSentEvent(data=json.dumps({\"metadata\": metadata}), event=\"data\")\n\n    @staticmethod\n    def replace_response_event(text: str) -> ServerSentEvent:\n        return ServerSentEvent(\n            data=json.dumps({\"text\": text}), event=\"replace_response\"\n        )\n\n    @staticmethod\n    def done_event() -> ServerSentEvent:\n        return ServerSentEvent(data=\"{}\", event=\"done\")\n\n    @staticmethod\n    def suggested_reply_event(text: str) -> ServerSentEvent:\n        return ServerSentEvent(data=json.dumps({\"text\": text}), event=\"suggested_reply\")\n\n    @staticmethod\n    def meta_event(\n        *,\n        content_type: ContentType = \"text/markdown\",\n        refetch_settings: bool = False,\n        linkify: bool = True,\n        suggested_replies: bool = False,\n    ) -> ServerSentEvent:\n        return ServerSentEvent(\n            data=json.dumps(\n                {\n                    \"content_type\": content_type,\n                    \"refetch_settings\": refetch_settings,\n                    \"linkify\": linkify,\n                    \"suggested_replies\": suggested_replies,\n                }\n            ),\n            event=\"meta\",\n        )\n\n    @staticmethod\n    def error_event(\n        text: Optional[str] = None,\n        *,\n        raw_response: Optional[object] = None,\n        allow_retry: bool = True,\n        error_type: Optional[str] = None,\n    ) -> ServerSentEvent:\n        data: dict[str, Union[bool, str]] = {\"allow_retry\": allow_retry}\n        if text is not None:\n            data[\"text\"] = text\n        if raw_response is not None:\n            data[\"raw_response\"] = repr(raw_response)\n        if error_type is not None:\n            data[\"error_type\"] = error_type\n        return ServerSentEvent(data=json.dumps(data), event=\"error\")\n\n    # Internal handlers\n\n    async def handle_report_feedback(\n        self, feedback_request: ReportFeedbackRequest, context: RequestContext\n    ) -> JSONResponse:\n        await self.on_feedback_with_context(feedback_request, context)\n        return JSONResponse({})\n\n    async def handle_report_reaction(\n        self, reaction_request: ReportReactionRequest, context: RequestContext\n    ) -> JSONResponse:\n        await self.on_reaction_with_context(reaction_request, context)\n        return JSONResponse({})\n\n    async def handle_report_error(\n        self, error_request: ReportErrorRequest, context: RequestContext\n    ) -> JSONResponse:\n        await self.on_error_with_context(error_request, context)\n        return JSONResponse({})\n\n    async def handle_settings(\n        self, settings_request: SettingsRequest, context: RequestContext\n    ) -> JSONResponse:\n        settings = await self.get_settings_with_context(settings_request, context)\n        return JSONResponse(settings.dict())\n\n    async def _yield_pending_file_events(\n        self, message_id: Identifier\n    ) -> AsyncIterable[ServerSentEvent]:\n        file_events_to_yield = self._file_events_to_yield.pop(message_id, [])\n        for fe in file_events_to_yield:\n            yield fe\n\n    async def handle_query(\n        self, request: QueryRequest, context: RequestContext\n    ) -> AsyncIterable[ServerSentEvent]:\n        try:\n            if self.should_insert_attachment_messages:\n                request = self.insert_attachment_messages(query_request=request)\n            elif self.concat_attachments_to_message:\n                warnings.warn(\n                    \"concat_attachments_to_message is deprecated. \"\n                    \"Use should_insert_attachment_messages instead.\",\n                    DeprecationWarning,\n                    stacklevel=2,\n                )\n                request = self.concat_attachment_content_to_message_body(\n                    query_request=request\n                )\n            async for event in self.get_response_with_context(request, context):\n                # yield any pending file events from post_message_attachment first.\n                # this is to ensure responses with inline_ref are sent after attachment is made.\n                async for pending_file_event in self._yield_pending_file_events(\n                    request.message_id\n                ):\n                    yield pending_file_event\n\n                if isinstance(event, PartialResponse) and event.attachment:\n                    attachment = event.attachment\n                    yield self.file_event(\n                        url=attachment.url,\n                        content_type=attachment.content_type,\n                        name=attachment.name,\n                        inline_ref=attachment.inline_ref,\n                    )\n\n                if isinstance(event, ServerSentEvent):\n                    yield event\n                elif isinstance(event, ErrorResponse):\n                    yield self.error_event(\n                        event.text,\n                        raw_response=event.raw_response,\n                        allow_retry=event.allow_retry,\n                        error_type=event.error_type,\n                    )\n                elif isinstance(event, MetaResponse):\n                    yield self.meta_event(\n                        content_type=event.content_type,\n                        refetch_settings=event.refetch_settings,\n                        linkify=event.linkify,\n                        suggested_replies=event.suggested_replies,\n                    )\n                elif isinstance(event, DataResponse):\n                    yield self.data_event(event.metadata)\n                elif event.is_suggested_reply:\n                    yield self.suggested_reply_event(event.text)\n                elif event.is_replace_response:\n                    yield self.replace_response_event(event.text)\n                else:\n                    yield self.text_event(event.text)\n\n            # yield any remaining file events\n            async for pending_file_event in self._yield_pending_file_events(\n                request.message_id\n            ):\n                yield pending_file_event\n        except Exception as e:\n            logger.exception(\"Error responding to query\")\n            yield self.error_event(\n                \"The bot encountered an unexpected issue.\",\n                raw_response=e,\n                allow_retry=False,\n            )\n        yield self.done_event()\n\n\ndef _find_access_key(*, access_key: str, api_key: str) -> Optional[str]:\n    \"\"\"Figures out the access key.\n\n    The order of preference is:\n    1) access_key=\n    2) $POE_ACCESS_KEY\n    3) api_key=\n    4) $POE_API_KEY\n\n    \"\"\"\n    if access_key:\n        return access_key\n\n    environ_poe_access_key = os.environ.get(\"POE_ACCESS_KEY\")\n    if environ_poe_access_key:\n        return environ_poe_access_key\n\n    if api_key:\n        warnings.warn(\n            \"usage of api_key is deprecated, pass your key using access_key instead\",\n            DeprecationWarning,\n            stacklevel=3,\n        )\n        return api_key\n\n    environ_poe_api_key = os.environ.get(\"POE_API_KEY\")\n    if environ_poe_api_key:\n        warnings.warn(\n            \"usage of POE_API_KEY is deprecated, pass your key using POE_ACCESS_KEY instead\",\n            DeprecationWarning,\n            stacklevel=3,\n        )\n        return environ_poe_api_key\n\n    return None\n\n\ndef _verify_access_key(\n    *, access_key: str, api_key: str, allow_without_key: bool = False\n) -> Optional[str]:\n    \"\"\"Checks whether we have a valid access key and returns it.\"\"\"\n    _access_key = _find_access_key(access_key=access_key, api_key=api_key)\n    if not _access_key:\n        if allow_without_key:\n            return None\n        print(\n            \"Please provide an access key.\\n\"\n            \"You can get a key from the create_bot page at: https://poe.com/create_bot?server=1\\n\"\n            \"You can then pass the key using the access_key param to the run() or make_app() \"\n            \"functions, or by using the POE_ACCESS_KEY environment variable.\"\n        )\n        sys.exit(1)\n    if len(_access_key) != 32:\n        print(\"Invalid access key (should be 32 characters)\")\n        sys.exit(1)\n    return _access_key\n\n\ndef _add_routes_for_bot(app: FastAPI, bot: PoeBot) -> None:\n    async def index() -> Response:\n        url = \"https://poe.com/create_bot?server=1\"\n        return HTMLResponse(\n            \"<html><body><h1>FastAPI Poe bot server</h1><p>Congratulations! Your server\"\n            \" is running. To connect it to Poe, create a bot at <a\"\n            f' href=\"{url}\">{url}</a>.</p></body></html>'\n        )\n\n    def auth_user(\n        authorization: HTTPAuthorizationCredentials = Depends(http_bearer),\n    ) -> None:\n        if bot.access_key is None:\n            return\n        if (\n            authorization.scheme != \"Bearer\"\n            or authorization.credentials != bot.access_key\n        ):\n            raise HTTPException(\n                status_code=401,\n                detail=\"Invalid access key\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n    async def poe_post(request: Request, dict: object = Depends(auth_user)) -> Response:\n        request_body = await request.json()\n        request_body[\"http_request\"] = request\n        if request_body[\"type\"] == \"query\":\n            return EventSourceResponse(\n                bot.handle_query(\n                    QueryRequest.parse_obj(\n                        {\n                            **request_body,\n                            \"access_key\": bot.access_key or \"<missing>\",\n                            \"api_key\": bot.access_key or \"<missing>\",\n                        }\n                    ),\n                    RequestContext(http_request=request),\n                )\n            )\n        elif request_body[\"type\"] == \"settings\":\n            return await bot.handle_settings(\n                SettingsRequest.parse_obj(request_body),\n                RequestContext(http_request=request),\n            )\n        elif request_body[\"type\"] == \"report_feedback\":\n            return await bot.handle_report_feedback(\n                ReportFeedbackRequest.parse_obj(request_body),\n                RequestContext(http_request=request),\n            )\n        elif request_body[\"type\"] == \"report_reaction\":\n            return await bot.handle_report_reaction(\n                ReportReactionRequest.parse_obj(request_body),\n                RequestContext(http_request=request),\n            )\n        elif request_body[\"type\"] == \"report_error\":\n            return await bot.handle_report_error(\n                ReportErrorRequest.parse_obj(request_body),\n                RequestContext(http_request=request),\n            )\n        else:\n            raise HTTPException(status_code=501, detail=\"Unsupported request type\")\n\n    app.get(bot.path)(index)\n    app.post(bot.path)(poe_post)\n\n\ndef make_app(\n    bot: Union[PoeBot, Sequence[PoeBot]],\n    access_key: str = \"\",\n    *,\n    bot_name: str = \"\",\n    api_key: str = \"\",\n    allow_without_key: bool = False,\n    app: Optional[FastAPI] = None,\n) -> FastAPI:\n    \"\"\"\n\n    Create an app object for your bot(s).\n\n    #### Parameters:\n    - `bot` (`Union[PoeBot, Sequence[PoeBot]]`): A bot object or a list of bot objects if you want\n    to host multiple bots on one server.\n    - `access_key` (`str = \"\"`): The access key to use.  If not provided, the server tries to\n    read the POE_ACCESS_KEY environment variable. If that is not set, the server will\n    refuse to start, unless `allow_without_key` is True. If multiple bots are provided,\n    the access key must be provided as part of the bot object.\n    - `bot_name` (`str = \"\"`): The name of the bot as it appears on poe.com.\n    - `api_key` (`str = \"\"`): **DEPRECATED**: Please set the access_key when creating the Bot\n    object instead.\n    - `allow_without_key` (`bool = False`): If True, the server will start even if no access\n    key is provided. Requests will not be checked against any key. If an access key is provided, it\n    is still checked.\n    - `app` (`Optional[FastAPI] = None`): A FastAPI app instance. If provided, the app will be\n    configured with the provided bots, access keys, and other settings. If not provided, a new\n    FastAPI application instance will be created and configured.\n    #### Returns:\n    - `FastAPI`: A FastAPI app configured to serve your bot when run.\n\n    \"\"\"\n    if app is None:\n        app = FastAPI()\n    app.add_exception_handler(RequestValidationError, http_exception_handler)\n\n    if isinstance(bot, PoeBot):\n        if bot.access_key is None:\n            bot.access_key = _verify_access_key(\n                access_key=access_key,\n                api_key=api_key,\n                allow_without_key=allow_without_key,\n            )\n        elif access_key:\n            raise ValueError(\n                \"Cannot provide access_key if the bot object already has an access key\"\n            )\n        elif api_key:\n            raise ValueError(\n                \"Cannot provide api_key if the bot object already has an access key\"\n            )\n\n        if bot.bot_name is None:\n            bot.bot_name = bot_name\n        elif bot_name:\n            raise ValueError(\n                \"Cannot provide bot_name if the bot object already has a bot_name\"\n            )\n        bots = [bot]\n    else:\n        if access_key or api_key or bot_name:\n            raise ValueError(\n                \"When serving multiple bots, the access_key/bot_name must be set on each bot\"\n            )\n        bots = bot\n\n    # Ensure paths are unique\n    path_to_bots = defaultdict(list)\n    for bot in bots:\n        path_to_bots[bot.path].append(bot)\n    for path, bots_of_path in path_to_bots.items():\n        if len(bots_of_path) > 1:\n            raise ValueError(\n                f\"Multiple bots are trying to use the same path: {path}: {bots_of_path}. \"\n                \"Please use a different path for each bot.\"\n            )\n\n    for bot_obj in bots:\n        if bot_obj.access_key is None and not allow_without_key:\n            raise ValueError(f\"Missing access key on {bot_obj}\")\n        _add_routes_for_bot(app, bot_obj)\n        if not bot_obj.bot_name or not bot_obj.access_key:\n            logger.warning(\"\\n************* Warning *************\")\n            logger.warning(\n                \"Bot name or access key is not set for PoeBot.\\n\"\n                \"Bot settings will NOT be synced automatically on server start/update.\"\n                \"Please remember to sync bot settings manually.\\n\\n\"\n                \"For more information, see: https://creator.poe.com/docs/server-bots/updating-bot-settings\"\n            )\n            logger.warning(\"\\n************* Warning *************\")\n        else:\n            try:\n                settings_response = asyncio.run(\n                    bot_obj.get_settings(\n                        SettingsRequest(version=PROTOCOL_VERSION, type=\"settings\")\n                    )\n                )\n                sync_bot_settings(\n                    bot_name=bot_obj.bot_name,\n                    settings=settings_response.model_dump(),\n                    access_key=bot_obj.access_key,\n                )\n            except Exception as e:\n                logger.error(\"\\n*********** Error ***********\")\n                logger.error(\n                    f\"Bot settings sync failed for {bot_obj.bot_name}: \\n{e}\\n\\n\"\n                )\n                logger.error(\"Please sync bot settings manually.\\n\\n\")\n                logger.error(\n                    \"For more information, see: https://creator.poe.com/docs/server-bots/updating-bot-settings\"\n                )\n                logger.error(\"\\n*********** Error ***********\")\n\n    # Uncomment this line to print out request and response\n    # app.add_middleware(LoggingMiddleware)\n    return app\n\n\ndef run(\n    bot: Union[PoeBot, Sequence[PoeBot]],\n    access_key: str = \"\",\n    *,\n    api_key: str = \"\",\n    allow_without_key: bool = False,\n    app: Optional[FastAPI] = None,\n) -> None:\n    \"\"\"\n\n    Serve a poe bot using a FastAPI app. This function should be used when you are running the\n    bot locally. The parameters are the same as they are for `make_app`.\n\n    #### Returns: `None`\n\n    \"\"\"\n\n    app = make_app(\n        bot,\n        access_key=access_key,\n        api_key=api_key,\n        allow_without_key=allow_without_key,\n        app=app,\n    )\n\n    parser = argparse.ArgumentParser(\"FastAPI sample Poe bot server\")\n    parser.add_argument(\"-p\", \"--port\", type=int, default=8080)\n    args = parser.parse_args()\n    port = args.port\n\n    logger.info(\"Starting\")\n    import uvicorn.config\n\n    log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)\n    log_config[\"formatters\"][\"default\"][\n        \"fmt\"\n    ] = \"%(asctime)s - %(levelname)s - %(message)s\"\n    uvicorn.run(app, host=\"0.0.0.0\", port=port, log_config=log_config)\n"
  },
  {
    "path": "src/fastapi_poe/client.py",
    "content": "\"\"\"\n\nClient for talking to other Poe bots through the Poe bot query API.\nFor more details, see: https://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe\n\n\"\"\"\n\nimport asyncio\nimport contextlib\nimport inspect\nimport io\nimport json\nimport os\nimport warnings\nfrom collections.abc import AsyncGenerator, Generator\nfrom dataclasses import dataclass, field\nfrom typing import Any, BinaryIO, Callable, Optional, Union, cast\n\nimport httpx\nimport httpx_sse\n\nfrom fastapi_poe.sync_utils import run_sync\n\nfrom .types import (\n    Attachment,\n    ContentType,\n    FunctionCallDefinition,\n    Identifier,\n    ProtocolMessage,\n    QueryRequest,\n    SettingsResponse,\n    ToolCallDefinition,\n    ToolCallDefinitionDelta,\n    ToolDefinition,\n    ToolResultDefinition,\n)\nfrom .types import MetaResponse as MetaMessage\nfrom .types import PartialResponse as BotMessage\n\nPROTOCOL_VERSION = \"1.2\"\nMESSAGE_LENGTH_LIMIT = 10_000\n\nIDENTIFIER_LENGTH = 32\nMAX_EVENT_COUNT = 1000\n\nErrorHandler = Callable[[Exception, str], None]\n\n\nclass AttachmentUploadError(Exception):\n    \"\"\"Raised when there is an error uploading an attachment.\"\"\"\n\n\nclass BotError(Exception):\n    \"\"\"Raised when there is an error communicating with the bot.\"\"\"\n\n\nclass BotErrorNoRetry(BotError):\n    \"\"\"Subclass of BotError raised when we're not allowed to retry.\"\"\"\n\n\nclass InvalidBotSettings(Exception):\n    \"\"\"Raised when a bot returns invalid settings.\"\"\"\n\n\ndef _safe_ellipsis(obj: object, limit: int) -> str:\n    if not isinstance(obj, str):\n        obj = repr(obj)\n    if len(obj) > limit:\n        obj = obj[: limit - 3] + \"...\"\n    return obj\n\n\n@dataclass\nclass _BotContext:\n    endpoint: str\n    session: httpx.AsyncClient = field(repr=False)\n    api_key: Optional[str] = field(default=None, repr=False)\n    on_error: Optional[ErrorHandler] = field(default=None, repr=False)\n    extra_headers: Optional[dict[str, str]] = field(default=None, repr=False)\n\n    @property\n    def headers(self) -> dict[str, str]:\n        headers = {\"Accept\": \"application/json\"}\n        if self.api_key is not None:\n            headers[\"Authorization\"] = f\"Bearer {self.api_key}\"\n        if self.extra_headers is not None:\n            headers.update(self.extra_headers)\n        return headers\n\n    async def report_error(\n        self, message: str, metadata: Optional[dict[str, Any]] = None\n    ) -> None:\n        \"\"\"Report an error to the bot server.\"\"\"\n        if self.on_error is not None:\n            long_message = (\n                f\"Protocol bot error: {message} with metadata {metadata} \"\n                f\"for endpoint {self.endpoint}\"\n            )\n            self.on_error(BotError(message), long_message)\n        await self.session.post(\n            self.endpoint,\n            headers=self.headers,\n            json={\n                \"version\": PROTOCOL_VERSION,\n                \"type\": \"report_error\",\n                \"message\": message,\n                \"metadata\": metadata or {},\n            },\n        )\n\n    async def report_feedback(\n        self,\n        message_id: Identifier,\n        user_id: Identifier,\n        conversation_id: Identifier,\n        feedback_type: str,\n    ) -> None:\n        \"\"\"Report message feedback to the bot server.\"\"\"\n        await self.session.post(\n            self.endpoint,\n            headers=self.headers,\n            json={\n                \"version\": PROTOCOL_VERSION,\n                \"type\": \"report_feedback\",\n                \"message_id\": message_id,\n                \"user_id\": user_id,\n                \"conversation_id\": conversation_id,\n                \"feedback_type\": feedback_type,\n            },\n        )\n\n    async def report_reaction(\n        self,\n        message_id: Identifier,\n        user_id: Identifier,\n        conversation_id: Identifier,\n        reaction: str,\n    ) -> None:\n        \"\"\"Report message reaction to the bot server.\"\"\"\n        await self.session.post(\n            self.endpoint,\n            headers=self.headers,\n            json={\n                \"version\": PROTOCOL_VERSION,\n                \"type\": \"report_reaction\",\n                \"message_id\": message_id,\n                \"user_id\": user_id,\n                \"conversation_id\": conversation_id,\n                \"reaction\": reaction,\n            },\n        )\n\n    async def fetch_settings(self) -> SettingsResponse:\n        \"\"\"Fetches settings from a Poe server bot endpoint.\"\"\"\n        resp = await self.session.post(\n            self.endpoint,\n            headers=self.headers,\n            json={\"version\": PROTOCOL_VERSION, \"type\": \"settings\"},\n        )\n        return resp.json()\n\n    async def perform_query_request(\n        self,\n        *,\n        request: QueryRequest,\n        tools: Optional[list[ToolDefinition]],\n        tool_calls: Optional[list[ToolCallDefinition]],\n        tool_results: Optional[list[ToolResultDefinition]],\n    ) -> AsyncGenerator[BotMessage, None]:\n        chunks: list[str] = []\n        message_id = request.message_id\n        event_count = 0\n        error_reported = False\n        payload = request.model_dump()\n        if tools is not None:\n            payload[\"tools\"] = [tool.model_dump() for tool in tools]\n        if tool_calls is not None:\n            payload[\"tool_calls\"] = [tool_call.model_dump() for tool_call in tool_calls]\n        if tool_results is not None:\n            payload[\"tool_results\"] = [\n                tool_result.model_dump() for tool_result in tool_results\n            ]\n        async with httpx_sse.aconnect_sse(\n            self.session, \"POST\", self.endpoint, headers=self.headers, json=payload\n        ) as event_source:\n            async for event in event_source.aiter_sse():\n                event_count += 1\n                index: Optional[int] = await self._get_single_json_integer_field_safe(\n                    event.data, event.event, message_id, \"index\"\n                )\n                if event.event == \"done\":\n                    # Don't send a report if we already told the bot about some other mistake.\n                    if not chunks and not error_reported and not tools:\n                        await self.report_error(\n                            \"Bot returned no text in response\",\n                            {\"message_id\": message_id},\n                        )\n                    return\n                elif event.event == \"text\":\n                    text = await self._get_single_json_field(\n                        event.data, \"text\", message_id\n                    )\n                elif event.event == \"replace_response\":\n                    text = await self._get_single_json_field(\n                        event.data, \"replace_response\", message_id\n                    )\n                    chunks.clear()\n                elif event.event == \"file\":\n                    yield BotMessage(\n                        text=\"\",\n                        attachment=Attachment(\n                            url=await self._get_single_json_field(\n                                event.data, \"file\", message_id, \"url\"\n                            ),\n                            content_type=await self._get_single_json_field(\n                                event.data, \"file\", message_id, \"content_type\"\n                            ),\n                            name=await self._get_single_json_field(\n                                event.data, \"file\", message_id, \"name\"\n                            ),\n                            inline_ref=await self._get_single_json_string_field_safe(\n                                event.data, \"file\", message_id, \"inline_ref\"\n                            ),\n                        ),\n                        index=index,\n                    )\n                    continue\n                elif event.event == \"suggested_reply\":\n                    text = await self._get_single_json_field(\n                        event.data, \"suggested_reply\", message_id\n                    )\n                    yield BotMessage(\n                        text=text,\n                        raw_response={\"type\": event.event, \"text\": event.data},\n                        full_prompt=repr(request),\n                        is_suggested_reply=True,\n                        index=index,\n                    )\n                    continue\n                elif event.event == \"json\":\n                    yield BotMessage(\n                        text=\"\",\n                        data=json.loads(event.data),\n                        full_prompt=repr(request),\n                        index=index,\n                    )\n                    continue\n                elif event.event == \"meta\":\n                    if event_count != 1:\n                        # spec says a meta event that is not the first event is ignored\n                        continue\n                    data = await self._load_json_dict(event.data, \"meta\", message_id)\n                    linkify = data.get(\"linkify\", False)\n                    if not isinstance(linkify, bool):\n                        await self.report_error(\n                            \"Invalid linkify value in 'meta' event\",\n                            {\"message_id\": message_id, \"linkify\": linkify},\n                        )\n                        error_reported = True\n                        continue\n                    send_suggested_replies = data.get(\"suggested_replies\", False)\n                    if not isinstance(send_suggested_replies, bool):\n                        await self.report_error(\n                            \"Invalid suggested_replies value in 'meta' event\",\n                            {\n                                \"message_id\": message_id,\n                                \"suggested_replies\": send_suggested_replies,\n                            },\n                        )\n                        error_reported = True\n                        continue\n                    content_type = data.get(\"content_type\", \"text/markdown\")\n                    if not isinstance(content_type, str):\n                        await self.report_error(\n                            \"Invalid content_type value in 'meta' event\",\n                            {\"message_id\": message_id, \"content_type\": content_type},\n                        )\n                        error_reported = True\n                        continue\n                    yield MetaMessage(\n                        text=\"\",\n                        raw_response=data,\n                        full_prompt=repr(request),\n                        linkify=linkify,\n                        suggested_replies=send_suggested_replies,\n                        content_type=cast(ContentType, content_type),\n                    )\n                    continue\n                elif event.event == \"error\":\n                    data = await self._load_json_dict(event.data, \"error\", message_id)\n                    if data.get(\"allow_retry\", True):\n                        raise BotError(event.data)\n                    else:\n                        raise BotErrorNoRetry(event.data)\n                elif event.event == \"ping\":\n                    # Not formally part of the spec, but FastAPI sends this; let's ignore it\n                    # instead of sending error reports.\n                    continue\n                else:\n                    # Truncate the type and message in case it's huge.\n                    await self.report_error(\n                        f\"Unknown event type: {_safe_ellipsis(event.event, 100)}\",\n                        {\n                            \"event_data\": _safe_ellipsis(event.data, 500),\n                            \"message_id\": message_id,\n                        },\n                    )\n                    error_reported = True\n                    continue\n                chunks.append(text)\n                yield BotMessage(\n                    text=text,\n                    raw_response={\"type\": event.event, \"text\": event.data},\n                    full_prompt=repr(request),\n                    is_replace_response=(event.event == \"replace_response\"),\n                    index=index,\n                )\n        await self.report_error(\n            \"Bot exited without sending 'done' event\", {\"message_id\": message_id}\n        )\n\n    async def _get_single_json_field(\n        self, data: str, context: str, message_id: Identifier, field: str = \"text\"\n    ) -> str:\n        data_dict = await self._load_json_dict(data, context, message_id)\n        text = data_dict[field]\n        if not isinstance(text, str):\n            await self.report_error(\n                f\"Expected string in '{field}' field for '{context}' event\",\n                {\"data\": data_dict, \"message_id\": message_id},\n            )\n            raise BotErrorNoRetry(f\"Expected string in '{context}' event\")\n        return text\n\n    async def _get_single_json_string_field_safe(\n        self, data: str, context: str, message_id: Identifier, field: str\n    ) -> Optional[str]:\n        data_dict = await self._load_json_dict(data, context, message_id)\n        if field not in data_dict:\n            return None\n        result = data_dict[field]\n        if not isinstance(result, str):\n            return None\n        return result\n\n    async def _get_single_json_integer_field_safe(\n        self, data: str, context: str, message_id: Identifier, field: str\n    ) -> Optional[int]:\n        data_dict = await self._load_json_dict(data, context, message_id)\n        if field not in data_dict:\n            return None\n        result = data_dict[field]\n        if not isinstance(result, int):\n            return None\n        return result\n\n    async def _load_json_dict(\n        self, data: str, context: str, message_id: Identifier\n    ) -> dict[str, object]:\n        try:\n            parsed = json.loads(data)\n        except json.JSONDecodeError:\n            await self.report_error(\n                f\"Invalid JSON in {context!r} event\",\n                {\"data\": data, \"message_id\": message_id},\n            )\n            # If they are returning invalid JSON, retrying immediately probably won't help\n            raise BotErrorNoRetry(f\"Invalid JSON in {context!r} event\") from None\n        if not isinstance(parsed, dict):\n            await self.report_error(\n                f\"Expected JSON dict in {context!r} event\",\n                {\"data\": data, \"message_id\": message_id},\n            )\n            raise BotError(f\"Expected JSON dict in {context!r} event\")\n        return cast(dict[str, object], parsed)\n\n\ndef _default_error_handler(e: Exception, msg: str) -> None:\n    print(\"Error in Poe bot:\", msg, \"\\n\", repr(e))\n\n\nasync def stream_request(\n    request: QueryRequest,\n    bot_name: str,\n    api_key: str = \"\",\n    *,\n    tools: Optional[list[ToolDefinition]] = None,\n    tool_executables: Optional[list[Callable]] = None,\n    access_key: str = \"\",\n    access_key_deprecation_warning_stacklevel: int = 2,\n    session: Optional[httpx.AsyncClient] = None,\n    on_error: ErrorHandler = _default_error_handler,\n    num_tries: int = 2,\n    retry_sleep_time: float = 0.5,\n    base_url: str = \"https://api.poe.com/bot/\",\n    extra_headers: Optional[dict[str, str]] = None,\n) -> AsyncGenerator[BotMessage, None]:\n    \"\"\"\n\n    The Entry point for the Bot Query API. This API allows you to use other bots on Poe for\n    inference in response to a user message. For more details, checkout:\n    https://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe\n\n    #### Parameters:\n    - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object\n    also includes information needed to identify the user for compute point usage.\n    - `bot_name` (`str`): The bot you want to invoke.\n    - `api_key` (`str = \"\"`): Your Poe API key, available at poe.com/api_key. You will need\n    this in case you are trying to use this function from a script/shell. Note that if an `api_key`\n    is provided, compute points will be charged on the account corresponding to the `api_key`.\n    - tools: (`Optional[list[ToolDefinition]] = None`): A list of ToolDefinition objects describing\n    the functions you have. This is used for OpenAI function calling.\n    - tool_executables: (`Optional[list[Callable]] = None`): A list of functions corresponding\n    to the ToolDefinitions. This is used for OpenAI function calling. When this is set, the\n    LLM-suggested tools will automatically run once, before passing the results back to the LLM for\n    a final response.\n\n    \"\"\"\n    if tools is not None:\n        async for message in _stream_request_with_tools(\n            request=request,\n            bot_name=bot_name,\n            api_key=api_key,\n            tools=tools,\n            tool_executables=tool_executables,\n            access_key=access_key,\n            access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,\n            session=session,\n            on_error=on_error,\n            num_tries=num_tries,\n            retry_sleep_time=retry_sleep_time,\n            base_url=base_url,\n            extra_headers=extra_headers,\n        ):\n            yield message\n\n    else:\n        async for message in stream_request_base(\n            request=request,\n            bot_name=bot_name,\n            api_key=api_key,\n            access_key=access_key,\n            access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,\n            session=session,\n            on_error=on_error,\n            num_tries=num_tries,\n            retry_sleep_time=retry_sleep_time,\n            base_url=base_url,\n            extra_headers=extra_headers,\n        ):\n            yield message\n\n\nasync def _get_tool_results(\n    tool_executables: list[Callable], tool_calls: list[ToolCallDefinition]\n) -> list[ToolResultDefinition]:\n    tool_executables_dict = {\n        executable.__name__: executable for executable in tool_executables\n    }\n    tool_results = []\n    for tool_call in tool_calls:\n        tool_call_id = tool_call.id\n        name = tool_call.function.name\n        arguments = json.loads(tool_call.function.arguments)\n        _func = tool_executables_dict[name]\n        if inspect.iscoroutinefunction(_func):\n            content = await _func(**arguments)\n        else:\n            content = _func(**arguments)\n        tool_results.append(\n            ToolResultDefinition(\n                role=\"tool\",\n                tool_call_id=tool_call_id,\n                name=name,\n                content=json.dumps(content),\n            )\n        )\n    return tool_results\n\n\nasync def _stream_request_with_tools(\n    request: QueryRequest,\n    bot_name: str,\n    api_key: str = \"\",\n    *,\n    tools: list[ToolDefinition],\n    tool_executables: Optional[list[Callable]] = None,\n    access_key: str = \"\",\n    access_key_deprecation_warning_stacklevel: int = 2,\n    session: Optional[httpx.AsyncClient] = None,\n    on_error: ErrorHandler = _default_error_handler,\n    num_tries: int = 2,\n    retry_sleep_time: float = 0.5,\n    base_url: str = \"https://api.poe.com/bot/\",\n    extra_headers: Optional[dict[str, str]] = None,\n) -> AsyncGenerator[BotMessage, None]:\n    aggregated_tool_calls: dict[int, ToolCallDefinition] = {}\n    async for message in stream_request_base(\n        request=request,\n        bot_name=bot_name,\n        api_key=api_key,\n        tools=tools,\n        access_key=access_key,\n        access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,\n        session=session,\n        on_error=on_error,\n        num_tries=num_tries,\n        retry_sleep_time=retry_sleep_time,\n        base_url=base_url,\n        extra_headers=extra_headers,\n    ):\n        if (\n            message.data is None\n            or \"choices\" not in message.data\n            or not message.data[\"choices\"]\n        ):\n            yield message\n            continue\n\n        # If there is a finish reason, skip the chunk. This should be the same as breaking out of\n        # the loop for most models, but we continue to cover situations where other kinds of\n        # chunks might stream in after the finish chunk.\n        finish_reason = message.data[\"choices\"][0][\"finish_reason\"]\n        if finish_reason is not None:\n            continue\n\n        if \"tool_calls\" in message.data[\"choices\"][0][\"delta\"]:\n            tool_call_deltas: list[ToolCallDefinitionDelta] = [\n                ToolCallDefinitionDelta(**tool_call_object)\n                for tool_call_object in message.data[\"choices\"][0][\"delta\"][\n                    \"tool_calls\"\n                ]\n            ]\n            # If tool_executables is not set, return the tool calls without executing them,\n            # allowing the caller to manage the tool call loop.\n            if tool_executables is None:\n                yield BotMessage(\n                    text=\"\", tool_calls=tool_call_deltas, index=message.index\n                )\n                continue\n\n            for tool_call_delta in tool_call_deltas:\n                if tool_call_delta.index not in aggregated_tool_calls:\n                    # The first chunk of a given index must contain id, type, and function.name.\n                    # If this first chunk is missing, the tool call for that index cannot be\n                    # aggregated.\n                    if (\n                        tool_call_delta.id is None\n                        or tool_call_delta.type is None\n                        or tool_call_delta.function.name is None\n                    ):\n                        continue\n\n                    aggregated_tool_calls[tool_call_delta.index] = ToolCallDefinition(\n                        id=tool_call_delta.id,\n                        type=tool_call_delta.type,\n                        function=FunctionCallDefinition(\n                            name=tool_call_delta.function.name,\n                            arguments=tool_call_delta.function.arguments,\n                        ),\n                    )\n                else:\n                    aggregated_tool_calls[\n                        tool_call_delta.index\n                    ].function.arguments += tool_call_delta.function.arguments\n\n        # if no tool calls are selected, the deltas contain content instead of tool_calls\n        elif \"content\" in message.data[\"choices\"][0][\"delta\"]:\n            yield BotMessage(\n                text=message.data[\"choices\"][0][\"delta\"][\"content\"], index=message.index\n            )\n\n    # If tool_executables is not set, exit early since there are no functions to execute.\n    if not tool_executables:\n        return\n\n    tool_calls: list[ToolCallDefinition] = list(aggregated_tool_calls.values())\n    tool_results = await _get_tool_results(tool_executables, tool_calls)\n\n    # If we have tool calls and tool results, we still need to get the final response from the\n    # LLM.\n    if tool_calls and tool_results:\n        async for message in stream_request_base(\n            request=request,\n            bot_name=bot_name,\n            api_key=api_key,\n            tools=tools,\n            tool_calls=tool_calls,\n            tool_results=tool_results,\n            access_key=access_key,\n            access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,\n            session=session,\n            on_error=on_error,\n            num_tries=num_tries,\n            retry_sleep_time=retry_sleep_time,\n            base_url=base_url,\n            extra_headers=extra_headers,\n        ):\n            yield message\n\n\nasync def stream_request_base(\n    request: QueryRequest,\n    bot_name: str,\n    api_key: str = \"\",\n    *,\n    tools: Optional[list[ToolDefinition]] = None,\n    tool_calls: Optional[list[ToolCallDefinition]] = None,\n    tool_results: Optional[list[ToolResultDefinition]] = None,\n    access_key: str = \"\",\n    access_key_deprecation_warning_stacklevel: int = 2,\n    session: Optional[httpx.AsyncClient] = None,\n    on_error: ErrorHandler = _default_error_handler,\n    num_tries: int = 2,\n    retry_sleep_time: float = 0.5,\n    base_url: str = \"https://api.poe.com/bot/\",\n    extra_headers: Optional[dict[str, str]] = None,\n) -> AsyncGenerator[BotMessage, None]:\n    if access_key != \"\":\n        warnings.warn(\n            \"the access_key param is no longer necessary when using this function.\",\n            DeprecationWarning,\n            stacklevel=access_key_deprecation_warning_stacklevel,\n        )\n\n    async with contextlib.AsyncExitStack() as stack:\n        if session is None:\n            session = await stack.enter_async_context(httpx.AsyncClient(timeout=600))\n        url = f\"{base_url}{bot_name}\"\n        ctx = _BotContext(\n            endpoint=url,\n            api_key=api_key,\n            session=session,\n            on_error=on_error,\n            extra_headers=extra_headers,\n        )\n        got_response = False\n        for i in range(num_tries):\n            try:\n                async for message in ctx.perform_query_request(\n                    request=request,\n                    tools=tools,\n                    tool_calls=tool_calls,\n                    tool_results=tool_results,\n                ):\n                    got_response = True\n                    yield message\n                break\n            except BotErrorNoRetry:\n                raise\n            except Exception as e:\n                on_error(e, f\"Bot request to {bot_name} failed on try {i}\")\n                # Want to retry on some errors even if we have streamed part of the request\n                # RemoteProtocolError: peer closed connection without sending complete message body\n                allow_retry_after_response = isinstance(e, httpx.RemoteProtocolError)\n                if (\n                    got_response and not allow_retry_after_response\n                ) or i == num_tries - 1:\n                    # If it's a BotError, it probably has a good error message\n                    # that we want to show directly.\n                    if isinstance(e, BotError):\n                        raise\n                    # But if it's something else (maybe an HTTP error or something),\n                    # wrap it in a BotError that makes it clear which bot is broken.\n                    raise BotError(f\"Error communicating with bot {bot_name}\") from e\n                await asyncio.sleep(retry_sleep_time)\n\n\ndef get_bot_response(\n    messages: list[ProtocolMessage],\n    bot_name: str,\n    api_key: str,\n    *,\n    tools: Optional[list[ToolDefinition]] = None,\n    tool_executables: Optional[list[Callable]] = None,\n    temperature: Optional[float] = None,\n    skip_system_prompt: Optional[bool] = None,\n    adopt_current_bot_name: Optional[bool] = None,\n    logit_bias: Optional[dict[str, float]] = None,\n    stop_sequences: Optional[list[str]] = None,\n    base_url: str = \"https://api.poe.com/bot/\",\n    session: Optional[httpx.AsyncClient] = None,\n) -> AsyncGenerator[BotMessage, None]:\n    \"\"\"\n\n    Use this function to invoke another Poe bot from your shell.\n\n    #### Parameters:\n    - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.\n    - `bot_name` (`str`): The bot that you want to invoke.\n    - `api_key` (`str`): Your Poe API key. Available at [poe.com/api_key](https://poe.com/api_key)\n    - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects\n    describing the functions you have. This is used for OpenAI function calling.\n    - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding\n    to the ToolDefinitions. This is used for OpenAI function calling.\n    - `temperature` (`Optional[float] = None`): The temperature to use for the bot.\n    - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.\n    - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.\n    - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.\n    - `base_url` (`str = \"https://api.poe.com/bot/\"`): The base URL to use for the bot. This is\n    mainly for internal testing and is not expected to be changed.\n    - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt\n    the identity of the calling bot\n    - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.\n    \"\"\"\n    additional_params = {}\n    # This is so that we don't have to redefine the default values for these params.\n    if temperature is not None:\n        additional_params[\"temperature\"] = temperature\n    if skip_system_prompt is not None:\n        additional_params[\"skip_system_prompt\"] = skip_system_prompt\n    if logit_bias is not None:\n        additional_params[\"logit_bias\"] = logit_bias\n    if stop_sequences is not None:\n        additional_params[\"stop_sequences\"] = stop_sequences\n    if adopt_current_bot_name is not None:\n        additional_params[\"adopt_current_bot_name\"] = adopt_current_bot_name\n\n    query = QueryRequest(\n        query=messages,\n        user_id=\"\",\n        conversation_id=\"\",\n        message_id=\"\",\n        version=PROTOCOL_VERSION,\n        type=\"query\",\n        **additional_params,\n    )\n    return stream_request(\n        request=query,\n        bot_name=bot_name,\n        api_key=api_key,\n        tools=tools,\n        tool_executables=tool_executables,\n        base_url=base_url,\n        session=session,\n    )\n\n\ndef get_bot_response_sync(\n    messages: list[ProtocolMessage],\n    bot_name: str,\n    api_key: str,\n    *,\n    tools: Optional[list[ToolDefinition]] = None,\n    tool_executables: Optional[list[Callable]] = None,\n    temperature: Optional[float] = None,\n    skip_system_prompt: Optional[bool] = None,\n    logit_bias: Optional[dict[str, float]] = None,\n    adopt_current_bot_name: Optional[bool] = None,\n    stop_sequences: Optional[list[str]] = None,\n    base_url: str = \"https://api.poe.com/bot/\",\n    session: Optional[httpx.AsyncClient] = None,\n) -> Generator[BotMessage, None, None]:\n    \"\"\"\n\n    This function wraps the async generator `fp.get_bot_response` and returns\n    partial responses synchronously.\n\n    For asynchronous streaming, or integration into an existing event loop, use\n    `fp.get_bot_response` directly.\n\n    #### Parameters:\n    - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.\n    - `bot_name` (`str`): The bot that you want to invoke.\n    - `api_key` (`str`): Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key)\n    - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects\n    describing the functions you have. This is used for OpenAI function calling.\n    - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding\n    to the ToolDefinitions. This is used for OpenAI function calling.\n    - `temperature` (`Optional[float] = None`): The temperature to use for the bot.\n    - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.\n    - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.\n    - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.\n    - `base_url` (`str = \"https://api.poe.com/bot/\"`): The base URL to use for the bot. This is\n    mainly for internal testing and is not expected to be changed.\n    - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt\n    the identity of the calling bot\n    - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.\n\n    \"\"\"\n\n    async def _async_generator() -> AsyncGenerator[BotMessage, None]:\n        async for partial in get_bot_response(\n            messages=messages,\n            bot_name=bot_name,\n            api_key=api_key,\n            tools=tools,\n            tool_executables=tool_executables,\n            temperature=temperature,\n            skip_system_prompt=skip_system_prompt,\n            adopt_current_bot_name=adopt_current_bot_name,\n            logit_bias=logit_bias,\n            stop_sequences=stop_sequences,\n            base_url=base_url,\n            session=session,\n        ):\n            yield partial\n\n    def _sync_generator() -> Generator[BotMessage, None, None]:\n        loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(loop)\n\n        async_gen = _async_generator().__aiter__()\n        try:\n            while True:\n                # Pull one item from the async generator at a time,\n                # blocking until it’s ready.\n                yield loop.run_until_complete(async_gen.__anext__())\n\n        except StopAsyncIteration:\n            pass\n\n        finally:\n            loop.run_until_complete(loop.shutdown_asyncgens())\n            loop.close()\n\n    return _sync_generator()\n\n\nasync def get_final_response(\n    request: QueryRequest,\n    bot_name: str,\n    api_key: str = \"\",\n    *,\n    access_key: str = \"\",\n    session: Optional[httpx.AsyncClient] = None,\n    on_error: ErrorHandler = _default_error_handler,\n    num_tries: int = 2,\n    retry_sleep_time: float = 0.5,\n    base_url: str = \"https://api.poe.com/bot/\",\n) -> str:\n    \"\"\"\n\n    A helper function for the bot query API that waits for all the tokens and concatenates the full\n    response before returning.\n\n    #### Parameters:\n    - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object\n    also includes information needed to identify the user for compute point usage.\n    - `bot_name` (`str`): The bot you want to invoke.\n    - `api_key` (`str = \"\"`): Your Poe API key, available at poe.com/api_key. You will need this in\n    case you are trying to use this function from a script/shell. Note that if an `api_key` is\n    provided, compute points will be charged on the account corresponding to the `api_key`.\n\n    \"\"\"\n    chunks: list[str] = []\n    async for message in stream_request(\n        request,\n        bot_name,\n        api_key,\n        access_key=access_key,\n        access_key_deprecation_warning_stacklevel=3,\n        session=session,\n        on_error=on_error,\n        num_tries=num_tries,\n        retry_sleep_time=retry_sleep_time,\n        base_url=base_url,\n    ):\n        if isinstance(message, MetaMessage):\n            continue\n        if message.is_suggested_reply:\n            continue\n        if message.is_replace_response:\n            chunks.clear()\n        chunks.append(message.text)\n    if not chunks:\n        raise BotError(f\"Bot {bot_name} sent no response\")\n    return \"\".join(chunks)\n\n\ndef sync_bot_settings(\n    bot_name: str,\n    access_key: str = \"\",\n    *,\n    settings: Optional[dict[str, Any]] = None,\n    base_url: str = \"https://api.poe.com/bot/\",\n) -> None:\n    \"\"\"Fetch settings from the running bot server, and then sync them with Poe.\"\"\"\n    try:\n        if settings is None:\n            response = httpx.post(\n                f\"{base_url}fetch_settings/{bot_name}/{access_key}/{PROTOCOL_VERSION}\"\n            )\n        else:\n            headers = {\"Content-Type\": \"application/json\"}\n            response = httpx.post(\n                f\"{base_url}update_settings/{bot_name}/{access_key}/{PROTOCOL_VERSION}\",\n                headers=headers,\n                json=settings,\n            )\n        if response.status_code != 200:\n            raise BotError(\n                f\"Error syncing settings for bot {bot_name}: {response.text}\"\n            )\n    except httpx.ReadTimeout as e:\n        error_message = f\"Timeout syncing settings for bot {bot_name}.\"\n        if not settings:\n            error_message += \" Check that the bot server is running.\"\n        raise BotError(error_message) from e\n    print(response.text)\n\n\nasync def upload_file(\n    file: Optional[Union[bytes, BinaryIO]] = None,\n    file_url: Optional[str] = None,\n    file_name: Optional[str] = None,\n    api_key: str = \"\",\n    *,\n    session: Optional[httpx.AsyncClient] = None,\n    on_error: ErrorHandler = _default_error_handler,\n    num_tries: int = 2,\n    retry_sleep_time: float = 0.5,\n    base_url: str = \"https://www.quora.com/poe_api/\",\n    extra_headers: Optional[dict[str, str]] = None,\n) -> Attachment:\n    \"\"\"\n    Upload a file (raw bytes *or* via URL) to Poe and receive an Attachment\n    object that can be returned directly from a bot or stored for later use.\n\n    #### Parameters:\n    - `file` (`Optional[Union[bytes, BinaryIO]] = None`): The file to upload.\n    - `file_url` (`Optional[str] = None`): The URL of the file to upload.\n    - `file_name` (`Optional[str] = None`): The name of the file to upload. Required if\n    `file` is provided as raw bytes.\n    - `api_key` (`str = \"\"`): Your Poe API key, available at poe.com/api_key. This can\n    also be the `access_key` if called from a Poe server bot.\n\n    #### Returns:\n    - `Attachment`: An Attachment object representing the uploaded file.\n\n    \"\"\"\n    if not api_key:\n        raise ValueError(\n            \"`api_key` is required (generate one at https://poe.com/api_key)\"\n        )\n    if (file is None and file_url is None) or (file and file_url):\n        raise ValueError(\"Provide either `file` or `file_url`, not both.\")\n\n    if file is not None and not file_name:\n        if isinstance(file, io.IOBase):\n            potential = getattr(file, \"name\", \"\")\n            if potential:\n                file_name = os.path.basename(potential)\n            if not file_name:\n                raise ValueError(\n                    \"`file_name` is mandatory when file object has no name attribute.\"\n                )\n        elif isinstance(file, (bytes, bytearray)):\n            raise ValueError(\"`file_name` is mandatory when sending raw bytes.\")\n        else:\n            raise ValueError(\"unsupported file type\")\n\n    endpoint = base_url.rstrip(\"/\") + \"/file_upload_3RD_PARTY_POST\"\n\n    async def _do_upload(_session: httpx.AsyncClient) -> Attachment:\n        headers = {\"Authorization\": api_key}\n        if extra_headers is not None:\n            headers.update(extra_headers)\n\n        if file_url:\n            data: dict[str, str] = {\"download_url\": file_url}\n            if file_name:\n                data[\"download_filename\"] = file_name\n            request = _session.build_request(\n                \"POST\", endpoint, data=data, headers=headers\n            )\n        else:  # raw bytes / BinaryIO\n            assert (\n                file is not None\n            ), \"file is required if file_url is not provided\"  # pyright\n            file_data = (\n                file.read() if not isinstance(file, (bytes, bytearray)) else file\n            )\n            files = {\"file\": (file_name, file_data)}\n            request = _session.build_request(\n                \"POST\", endpoint, files=files, headers=headers\n            )\n\n        response = await _session.send(request)\n\n        if response.status_code != 200:\n            # collect full error text (endpoint streams errors)\n            try:\n                err_txt = await response.aread()\n            except Exception:\n                err_txt = response.text\n            raise AttachmentUploadError(\n                f\"{response.status_code} {response.reason_phrase}: {err_txt}\"\n            )\n\n        data = response.json()\n        if not {\"attachment_url\", \"mime_type\"}.issubset(data):\n            raise AttachmentUploadError(f\"Unexpected response format: {data}\")\n\n        return Attachment(\n            url=data[\"attachment_url\"],\n            content_type=data[\"mime_type\"],\n            name=file_name or \"file\",\n        )\n\n    # retry wrapper\n    _sess = session or httpx.AsyncClient(timeout=120)\n    async with _sess:\n        for attempt in range(num_tries):\n            try:\n                return await _do_upload(_sess)\n            except Exception as e:\n                on_error(e, f\"upload attempt {attempt+1}/{num_tries} failed\")\n                if attempt == num_tries - 1:\n                    raise\n                await asyncio.sleep(retry_sleep_time)\n\n    raise AssertionError(\"retries exhausted\")  # unreachable, but satisfies pyright\n\n\ndef upload_file_sync(\n    file: Optional[Union[bytes, BinaryIO]] = None,\n    file_url: Optional[str] = None,\n    file_name: Optional[str] = None,\n    api_key: str = \"\",\n    *,\n    session: Optional[httpx.AsyncClient] = None,\n    on_error: ErrorHandler = _default_error_handler,\n    num_tries: int = 2,\n    retry_sleep_time: float = 0.5,\n    base_url: str = \"https://www.quora.com/poe_api/\",\n    extra_headers: Optional[dict[str, str]] = None,\n) -> Attachment:\n    \"\"\"\n    This is a synchronous wrapper around the async `upload_file`.\n\n    \"\"\"\n    coro = upload_file(\n        file=file,\n        file_url=file_url,\n        file_name=file_name,\n        api_key=api_key,\n        session=session,\n        on_error=on_error,\n        num_tries=num_tries,\n        retry_sleep_time=retry_sleep_time,\n        base_url=base_url,\n        extra_headers=extra_headers,\n    )\n    return run_sync(coro, session=session)\n"
  },
  {
    "path": "src/fastapi_poe/py.typed",
    "content": ""
  },
  {
    "path": "src/fastapi_poe/sync_utils.py",
    "content": "\"\"\"\nUtility helpers for running async functions from synchronous code.\n\n1. If there is no running event loop, just `asyncio.run`.\n2. If there is a running loop, spin up a background thread that has\n   its own loop and execute the coroutine there.\n3. If the caller passes in an `httpx.AsyncClient` (or any external\n   resource bound to the outer loop) while a loop is already running,\n   raise because that resource cannot be reused safely in the thread.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport concurrent.futures\nfrom collections.abc import Coroutine\nfrom typing import Any, Callable, TypeVar\n\nT = TypeVar(\"T\")\n\n\ndef run_sync(\n    coro: Coroutine[Any, Any, T],\n    *,\n    session: object | None = None,\n    _executor_factory: Callable[[], concurrent.futures.Executor] | None = None,\n) -> T:\n    \"\"\"\n    Run *coro* synchronously and return its result.\n\n    Parameters\n    ----------\n    coro:\n        Any awaitable (usually an async function call).\n    session:\n        Optional resource tied to the outer loop; if supplied while\n        already inside a loop we refuse to continue.\n    _executor_factory:\n        *Test seam* - lets tests inject a dummy executor.\n\n    Raises\n    ------\n    ValueError\n        If called from inside an event loop *and* ``session`` is passed in.\n    \"\"\"\n    # ──────────────────────────────\n    # 1. No running loop\n    # ──────────────────────────────\n    try:\n        asyncio.get_running_loop()\n    except RuntimeError:\n        return asyncio.run(coro)\n\n    # ──────────────────────────────\n    # 2. Running loop\n    # ──────────────────────────────\n    if session is not None:\n        raise ValueError(\n            \"run_sync called from within an async environment but a \"\n            \"`session` bound to that loop was supplied.  Pass no session \"\n            \"or call the async variant directly.\"\n        )\n\n    def _runner() -> T:\n        return asyncio.run(coro)\n\n    executor = (\n        _executor_factory()\n        if _executor_factory is not None\n        else concurrent.futures.ThreadPoolExecutor(max_workers=1)\n    )\n    # Use a context manager only when we created the executor ourselves.\n    if _executor_factory is None:\n        with executor:\n            future = executor.submit(_runner)\n            return future.result()\n    else:\n        # In tests where a fake executor is injected we assume it stays alive.\n        future = executor.submit(_runner)\n        return future.result()\n"
  },
  {
    "path": "src/fastapi_poe/templates.py",
    "content": "\"\"\"\n\nThis module contains a collection of string templates designed to streamline the integration\nof features like attachments into the LLM request.\n\n\"\"\"\n\nTEXT_ATTACHMENT_TEMPLATE = (\n    \"\"\"Below is the content of {attachment_name}:\\n\\n{attachment_parsed_content}\"\"\"\n)\nURL_ATTACHMENT_TEMPLATE = (\n    \"\"\"Assume you can access the external URL {attachment_name}. \"\"\"\n    \"\"\"Use the URL's content below to respond to the queries:\\n\\n{content}\"\"\"\n)\nIMAGE_VISION_ATTACHMENT_TEMPLATE = (\n    \"\"\"I have uploaded an image ({filename}). \"\"\"\n    \"\"\"Assume that you can see the attached image. \"\"\"\n    \"\"\"First, read the image analysis:\\n\\n\"\"\"\n    \"\"\"<image_analysis>{parsed_image_description}</image_analysis>\\n\\n\"\"\"\n    \"\"\"Use any relevant parts to inform your response. \"\"\"\n    \"\"\"Do NOT reference the image analysis in your response. \"\"\"\n    \"\"\"Respond in the same language as my next message. \"\"\"\n)\n"
  },
  {
    "path": "src/fastapi_poe/types.py",
    "content": "import math\nfrom typing import Any, Optional, Union, cast, get_args\n\nfrom fastapi import Request\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator\nfrom typing_extensions import Literal, TypeAlias\n\nIdentifier: TypeAlias = str\nFeedbackType: TypeAlias = Literal[\"like\", \"dislike\"]\nContentType: TypeAlias = Literal[\"text/markdown\", \"text/plain\"]\nMessageType: TypeAlias = Literal[\"function_call\"]\nErrorType: TypeAlias = Literal[\n    \"user_message_too_long\",\n    \"insufficient_fund\",\n    \"user_caused_error\",\n    \"privacy_authorization_error\",\n]\n\n\nclass MessageFeedback(BaseModel):\n    \"\"\"\n\n    Feedback for a message as used in the Poe protocol.\n    #### Fields:\n    - `type` (`FeedbackType`)\n    - `reason` (`Optional[str]`)\n\n    \"\"\"\n\n    type: FeedbackType\n    reason: Optional[str]\n\n\nclass CostItem(BaseModel):\n    \"\"\"\n\n    An object representing a cost item used for authorization and charge request.\n    #### Fields:\n    - `amount_usd_milli_cents` (`int`)\n    - `description` (`str`)\n\n    \"\"\"\n\n    amount_usd_milli_cents: int\n    description: Optional[str] = None\n\n    @field_validator(\"amount_usd_milli_cents\", mode=\"before\")\n    def validate_amount_is_int(cls, v: Union[int, str, float]) -> int:\n        if isinstance(v, float):\n            return math.ceil(v)\n        if not isinstance(v, int):\n            raise ValueError(\n                \"Invalid amount: expected an integer for amount_usd_milli_cents, \"\n                f\"got {type(v)}. Please provide the amount in milli-cents \"\n                \"(1/1000 of a cent) as a whole number. If you're working with a \"\n                \"decimal value, consider using math.ceil() to round up.\"\n            )\n        return v\n\n\nclass Attachment(BaseModel):\n    \"\"\"\n\n    Attachment included in a protocol message.\n    #### Fields:\n    - `url` (`str`): The download URL of the attachment.\n    - `content_type` (`str`): The MIME type of the attachment.\n    - `name` (`str`): The name of the attachment.\n    - `inline_ref` (`Optional[str] = None`): Set this to make Poe render the attachment inline.\n        You can then reference the attachment inline using ![title][inline_ref].\n    - `parsed_content` (`Optional[str] = None`): The parsed content of the attachment.\n\n    \"\"\"\n\n    url: str\n    content_type: str\n    name: str\n    inline_ref: Optional[str] = None\n    parsed_content: Optional[str] = None\n\n\nclass MessageReaction(BaseModel):\n    \"\"\"\n\n    Reaction to a message.\n    #### Fields:\n    - `user_id` (`Identifier`): An anonymized identifier representing the\n    user who reacted to the message.\n    - `reaction` (`str`): The reaction to the message.\n\n    \"\"\"\n\n    user_id: Identifier\n    reaction: str\n\n\nclass Sender(BaseModel):\n    \"\"\"\n\n    Sender of a message.\n    #### Fields:\n    - `id` (`Optional[Identifier] = None`): An anonymized identifier representing the sender.\n    - `name` (`Optional[str] = None`): The name of the sender.\n    If sender is a bot, this will be the name of the bot.\n    If sender is a user, this will be the name of the user if user name is available for this chat.\n    Typically, user name is only available in a chat of multiple users. Please note that a user\n    can change their name anytime and different users with different `id` can share the same name.\n\n    \"\"\"\n\n    id: Optional[Identifier] = None\n    name: Optional[str] = None\n\n\nclass User(BaseModel):\n    \"\"\"\n\n    User in a chat.\n    #### Fields:\n    - `id` (`Identifier`): An anonymized identifier representing a user.\n    - `name` (`Optional[str] = None`): The name of the user if user name is available for this chat.\n    Typically, user name is only available in a chat of multiple users. Please note that a user\n    can change their name anytime and different users with different `id` can share the same name.\n\n    \"\"\"\n\n    id: Identifier\n    name: Optional[str] = None\n\n\nclass ProtocolMessage(BaseModel):\n    \"\"\"\n\n    A message as used in the Poe protocol.\n    #### Fields:\n    - `role` (`Literal[\"system\", \"user\", \"bot\", \"tool\"]`): Message sender role.\n    - `message_type` (`Optional[MessageType] = None`): Type of the message.\n    - `sender_id` (`Optional[str]`): Sender ID of the message. This is deprecated, use\n      `sender` instead.\n    - `sender` (`Optional[Sender] = None`): Sender of the message.\n    - `content` (`str`): Content of the message.\n    - `parameters` (`dict[str, Any] = {}`): Parameters for the message.\n    - `content_type` (`ContentType=\"text/markdown\"`): Content type of the message.\n    - `timestamp` (`int = 0`): Timestamp of the message.\n    - `message_id` (`str = \"\"`): Message ID for the message.\n    - `feedback` (`list[MessageFeedback] = []`): Feedback for the message.\n    - `attachments` (`list[Attachment] = []`): Attachments for the message.\n    - `metadata` (`Optional[str] = None`): Metadata associated with the message.\n    - `referenced_message` (`Optional[\"ProtocolMessage\"] = None`): Message referenced by\n      this message (if any).\n    - `reactions` (`list[MessageReaction] = []`): Reactions to the message.\n\n    \"\"\"\n\n    role: Literal[\"system\", \"user\", \"bot\", \"tool\"]\n    message_type: Optional[MessageType] = None\n    sender_id: Optional[str] = None\n    sender: Optional[Sender] = None\n    content: str\n    parameters: dict[str, Any] = {}\n    content_type: ContentType = \"text/markdown\"\n    timestamp: int = 0\n    message_id: str = \"\"\n    feedback: list[MessageFeedback] = Field(default_factory=list)\n    attachments: list[Attachment] = Field(default_factory=list)\n    metadata: Optional[str] = None\n    referenced_message: Optional[\"ProtocolMessage\"] = None\n    reactions: list[MessageReaction] = Field(default_factory=list)\n\n\nclass RequestContext(BaseModel):\n    class Config:\n        arbitrary_types_allowed = True\n\n    http_request: Request\n\n\nclass ParametersDefinition(BaseModel):\n    \"\"\"\n\n    Parameters definition for function calling.\n    #### Fields:\n    - `type` (`str`)\n    - `properties` (`dict[str, object]`)\n    - `required` (`Optional[list[str]]`)\n\n    \"\"\"\n\n    type: str  # noqa: A003\n    properties: dict[str, object]\n    required: Optional[list[str]] = None\n\n\nclass FunctionDefinition(BaseModel):\n    \"\"\"\n\n    Function definition for OpenAI function calling.\n    #### Fields:\n    - `name` (`str`)\n    - `description` (`str`)\n    - `parameters` (`ParametersDefinition`)\n\n    \"\"\"\n\n    name: str\n    description: str\n    parameters: ParametersDefinition\n\n\nclass ToolDefinition(BaseModel):\n    \"\"\"\n\n    An object representing a tool definition used for OpenAI function calling.\n    #### Fields:\n    - `type` (`str`)\n    - `function` (`FunctionDefinition`): Look at the source code for a detailed description\n    of what this means.\n\n    \"\"\"\n\n    type: str\n    function: FunctionDefinition\n\n\nclass CustomToolDefinition(BaseModel):\n    \"\"\"Custom tool definition for OpenAI-compatible custom tools.\n\n    Corresponds to `chat_completion_custom_tool_param.Custom` but\n    with a looser format specification.\n\n    \"\"\"\n\n    name: str\n    description: Optional[str] = None\n    format_: Optional[dict[str, Any]] = Field(default=None, alias=\"format\")\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass FunctionCallDefinition(BaseModel):\n    \"\"\"\n\n    Function call definition for OpenAI function calling.\n    #### Fields:\n    - `name` (`str`)\n    - `arguments` (`str`)\n\n    \"\"\"\n\n    name: str\n    arguments: str\n\n\nclass ToolCallDefinition(BaseModel):\n    \"\"\"\n\n    An object representing a tool call. This is returned as a response by the model when using\n    OpenAI function calling.\n    #### Fields:\n    - `id` (`str`)\n    - `type` (`str`)\n    - `function` (`FunctionCallDefinition`): The function name (string) and arguments (JSON string).\n\n    \"\"\"\n\n    id: str\n    type: str\n    function: FunctionCallDefinition\n\n\nclass CustomCallDefinition(BaseModel):\n    \"\"\"Custom tool call in model response.\n\n    Corresponds to `chat_completion_message_custom_tool_call.Custom`.\n\n    \"\"\"\n\n    name: str\n    input_: str = Field(alias=\"input\")\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass ToolResultDefinition(BaseModel):\n    \"\"\"\n\n    An object representing a function result. This is passed to the model in the last step\n    when using OpenAI function calling.\n    #### Fields:\n    - `role` (`str`)\n    - `name` (`str`)\n    - `tool_call_id` (`str`)\n    - `content` (`str`)\n\n    \"\"\"\n\n    role: str\n    name: str\n    tool_call_id: str\n    content: str\n\n\nclass FunctionCallDefinitionDelta(BaseModel):\n    \"\"\"\n\n    Function call definition delta for streaming OpenAI function calling.\n    #### Fields:\n    - `name` (`Optional[str]`)\n    - `arguments` (`str`)\n\n    \"\"\"\n\n    name: Optional[str] = None\n    arguments: str\n\n\nclass ToolCallDefinitionDelta(BaseModel):\n    \"\"\"\n\n    An object representing a tool call chunk. This is returned as a streamed response by the model\n    when using OpenAI function calling. This may be an incomplete tool call definition (e.g. with\n    the function name set with the arguments not yet filled in), so the index can be used to\n    identify which tool call this chunk belongs to. Chunks may have null id, type, and\n    function.name values.\n    See https://platform.openai.com/docs/guides/function-calling#streaming for examples.\n    #### Fields:\n    - `index` (`int`): used to identify to which tool call this chunk belongs.\n    - `id` (`Optional[str] = None`): The tool call ID. This helps the model identify previous tool\n    call suggestions and help optimize tool call loops.\n    - `type` (`Optional[str] = None`): The type of the tool call (always function for function\n    calls).\n    - `function` (`FunctionCallDefinitionDelta`): The function name (string) and arguments (JSON\n    string).\n\n    \"\"\"\n\n    index: int = 0\n    id: Optional[str] = None\n    type: Optional[str] = None\n    function: FunctionCallDefinitionDelta\n\n\nclass BaseRequest(BaseModel):\n    \"\"\"Common data for all requests.\"\"\"\n\n    version: str\n    type: Literal[\n        \"query\", \"settings\", \"report_feedback\", \"report_reaction\", \"report_error\"\n    ]\n\n\nclass QueryRequest(BaseRequest):\n    \"\"\"\n\n    Request parameters for a query request.\n    #### Fields:\n    - `query` (`list[ProtocolMessage]`): list of message representing the current state of the chat.\n    - `user_id` (`Identifier`): an anonymized identifier representing a user. This is persistent\n    for subsequent requests from that user.\n    - `conversation_id` (`Identifier`): an identifier representing a chat. This is\n    persistent for subsequent request for that chat.\n    - `message_id` (`Identifier`): an identifier representing a message.\n    - `access_key` (`str = \"<missing>\"`): contains the access key defined when you created your bot\n    on Poe.\n    - `temperature` (`float | None = None`): Temperature input to be used for model inference.\n    - `skip_system_prompt` (`bool = False`): Whether to use any system prompting or not.\n    - `logit_bias` (`dict[str, float] = {}`)\n    - `stop_sequences` (`list[str] = []`)\n    - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt\n    the identity of the calling bot\n    - `language_code` (`str = \"en\"`): BCP 47 language code of the user's client.\n    - `bot_query_id` (`str = \"\"`): an identifier representing a bot query.\n    - `users` (`list[User] = []`): list of users in the chat.\n    - `tools` (`Optional[list[ToolDefinition]] = None`): List of tool definitions for\n    function calling.\n    - `tool_calls` (`Optional[list[ToolCallDefinition]] = None`): List of tool calls\n    made by the assistant.\n    - `tool_results` (`Optional[list[ToolResultDefinition]] = None`): List of tool\n    execution results.\n    - `query_creation_time` (`Optional[int] = None`): Timestamp when the query was\n    created (microseconds).\n    - `extra_params` (`Optional[dict[str, Any]] = None`): Additional parameters for\n    the request.\n\n    \"\"\"\n\n    query: list[ProtocolMessage]\n    user_id: Identifier\n    conversation_id: Identifier\n    message_id: Identifier\n    metadata: Identifier = \"\"\n    api_key: str = \"<missing>\"\n    access_key: str = \"<missing>\"\n    temperature: Optional[float] = None\n    skip_system_prompt: bool = False\n    logit_bias: dict[str, float] = {}\n    stop_sequences: list[str] = []\n    language_code: str = \"en\"\n    adopt_current_bot_name: Optional[bool] = None\n    bot_query_id: Identifier = \"\"\n    users: list[User] = []\n    # Fields below are for compatibility with aiohttp_poe.types.QueryRequest\n    tools: Optional[list[ToolDefinition]] = None\n    tool_calls: Optional[list[ToolCallDefinition]] = None\n    tool_results: Optional[list[ToolResultDefinition]] = None\n    query_creation_time: Optional[int] = None\n    extra_params: Optional[dict[str, Any]] = None\n\n    @classmethod\n    def from_dict(cls, data: dict[str, Any]) -> \"QueryRequest\":\n        \"\"\"Create QueryRequest from a dictionary (e.g., from aiohttp_poe format).\"\"\"\n        # Convert tool definitions if present\n        if \"tools\" in data and data[\"tools\"] is not None:\n            data[\"tools\"] = [\n                ToolDefinition.model_validate(tool) if isinstance(tool, dict) else tool\n                for tool in data[\"tools\"]\n            ]\n\n        # Convert tool calls if present\n        if \"tool_calls\" in data and data[\"tool_calls\"] is not None:\n            data[\"tool_calls\"] = [\n                ToolCallDefinition.model_validate(tc) if isinstance(tc, dict) else tc\n                for tc in data[\"tool_calls\"]\n            ]\n\n        # Convert tool results if present\n        if \"tool_results\" in data and data[\"tool_results\"] is not None:\n            data[\"tool_results\"] = [\n                ToolResultDefinition.model_validate(tr) if isinstance(tr, dict) else tr\n                for tr in data[\"tool_results\"]\n            ]\n\n        return cls.model_validate(data)\n\n\nclass SettingsRequest(BaseRequest):\n    \"\"\"\n\n    Request parameters for a settings request. Currently, this contains no fields but this\n    might get updated in the future.\n\n    \"\"\"\n\n\nclass ReportFeedbackRequest(BaseRequest):\n    \"\"\"\n\n    Request parameters for a report_feedback request.\n    #### Fields:\n    - `message_id` (`Identifier`)\n    - `user_id` (`Identifier`)\n    - `conversation_id` (`Identifier`)\n    - `feedback_type` (`FeedbackType`)\n\n    \"\"\"\n\n    message_id: Identifier\n    user_id: Identifier\n    conversation_id: Identifier\n    feedback_type: FeedbackType\n\n\nclass ReportReactionRequest(BaseRequest):\n    \"\"\"\n\n    Request parameters for a report_reaction request.\n    #### Fields:\n    - `message_id` (`Identifier`)\n    - `user_id` (`Identifier`)\n    - `conversation_id` (`Identifier`)\n    - `reaction` (`str`)\n\n    \"\"\"\n\n    message_id: Identifier\n    user_id: Identifier\n    conversation_id: Identifier\n    reaction: str\n\n\nclass ReportErrorRequest(BaseRequest):\n    \"\"\"\n\n    Request parameters for a report_error request.\n    #### Fields:\n    - `message` (`str`)\n    - `metadata` (`dict[str, Any]`)\n\n    \"\"\"\n\n    message: str\n    metadata: dict[str, Any]\n\n\nNumber = Union[int, float]\n\n\nclass Divider(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"divider\"] = \"divider\"\n\n\nclass TextField(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"text_field\"] = \"text_field\"\n    label: str\n    description: Optional[str] = None\n    parameter_name: str\n    default_value: Optional[str] = None\n    placeholder: Optional[str] = None\n\n\nclass TextArea(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"text_area\"] = \"text_area\"\n    label: str\n    description: Optional[str] = None\n    parameter_name: str\n    default_value: Optional[str] = None\n    placeholder: Optional[str] = None\n\n\nclass ValueNamePair(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    value: str\n    name: str\n\n\nclass DropDown(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"drop_down\"] = \"drop_down\"\n    label: str\n    description: Optional[str] = None\n    parameter_name: str\n    default_value: Optional[str] = None\n    options: list[ValueNamePair]\n\n\nclass ToggleSwitch(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"toggle_switch\"] = \"toggle_switch\"\n    label: str\n    description: Optional[str] = None\n    parameter_name: str\n    default_value: Optional[bool] = None\n\n\nclass Slider(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"slider\"] = \"slider\"\n    label: str\n    description: Optional[str] = None\n    parameter_name: str\n    default_value: Optional[Number] = None\n    min_value: Number\n    max_value: Number\n    step: Number\n\n\nclass AspectRatioOption(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    value: Optional[str] = None\n    width: Number\n    height: Number\n\n\nclass AspectRatio(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"aspect_ratio\"] = \"aspect_ratio\"\n    label: str\n    description: Optional[str] = None\n    parameter_name: str\n    default_value: Optional[str] = None\n    options: list[AspectRatioOption]\n\n\nBaseControl = Union[\n    Divider, TextField, TextArea, DropDown, ToggleSwitch, Slider, AspectRatio\n]\n\n\nclass LiteralValue(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    literal: Union[str, float, int, bool]\n\n\nclass ParameterValue(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    parameter_name: str\n\n\nclass ComparatorCondition(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    comparator: Literal[\"eq\", \"ne\", \"gt\", \"ge\", \"lt\", \"le\"]\n    left: Union[LiteralValue, ParameterValue]\n    right: Union[LiteralValue, ParameterValue]\n\n\nclass ConditionallyRenderControls(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    control: Literal[\"condition\"] = \"condition\"\n    condition: ComparatorCondition\n    controls: list[BaseControl]\n\n\nFullControls = Union[\n    Divider,\n    TextField,\n    TextArea,\n    DropDown,\n    ToggleSwitch,\n    Slider,\n    AspectRatio,\n    ConditionallyRenderControls,\n]\n\n\nclass Tab(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    name: Optional[str] = None\n    controls: list[FullControls]\n\n\nclass Section(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    name: Optional[str] = None\n    controls: Optional[list[FullControls]] = None\n    tabs: Optional[list[Tab]] = None\n    collapsed_by_default: Optional[bool] = None\n\n\nclass ParameterControls(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n\n    api_version: Literal[\"2\"] = \"2\"\n    sections: list[Section]\n\n\nclass SettingsResponse(BaseModel):\n    \"\"\"\n\n    An object representing your bot's response to a settings object.\n    #### Fields:\n    - `response_version` (`int = 2`): Different Poe Protocol versions use different default settings\n    values. When provided, Poe will use the default values for the specified response version.\n    If not provided, Poe will use the default values for response version 0.\n    - `server_bot_dependencies` (`dict[str, int] = {}`): Information about other bots that your bot\n    uses. This is used to facilitate the Bot Query API.\n    - `allow_attachments` (`bool = True`): Whether to allow users to upload attachments to your\n    bot.\n    - `introduction_message` (`str = \"\"`): The introduction message to display to the users of your\n    bot.\n    - `expand_text_attachments` (`bool = True`): Whether to request parsed content/descriptions from\n    text attachments with the query request. This content is sent through the new parsed_content\n    field in the attachment dictionary. This change makes enabling file uploads much simpler.\n    - `enable_image_comprehension` (`bool = False`): Similar to `expand_text_attachments` but for\n    images.\n    - `enforce_author_role_alternation` (`bool = False`): If enabled, Poe will concatenate messages\n    so that they follow role alternation, which is a requirement for certain LLM providers like\n    Anthropic.\n    - `enable_multi_entity_prompting` (`bool = True`): If enabled, Poe will combine previous bot\n    messages if there is a multientity context.\n    - `parameter_controls` (`Optional[ParameterControls] = None`): Optional JSON object that defines\n    interactive parameter controls. The object must contain an api_version and sections array.\n\n    \"\"\"\n\n    model_config = ConfigDict(extra=\"forbid\")\n\n    response_version: Optional[int] = 2\n    context_clear_window_secs: Optional[int] = None  # deprecated\n    allow_user_context_clear: Optional[bool] = None  # deprecated\n    custom_rate_card: Optional[str] = None  # deprecated\n    server_bot_dependencies: dict[str, int] = Field(default_factory=dict)\n    allow_attachments: Optional[bool] = None\n    introduction_message: Optional[str] = None\n    expand_text_attachments: Optional[bool] = None\n    enable_image_comprehension: Optional[bool] = None\n    enforce_author_role_alternation: Optional[bool] = None\n    enable_multi_bot_chat_prompting: Optional[bool] = None  # deprecated\n    enable_multi_entity_prompting: Optional[bool] = None\n    rate_card: Optional[str] = None\n    cost_label: Optional[str] = None\n    parameter_controls: Optional[ParameterControls] = None\n\n\nclass AttachmentUploadResponse(BaseModel):\n    \"\"\"\n\n    The result of a post_message_attachment request or file event in bot response.\n    #### Fields:\n    - `attachment_url` (`Optional[str]`): The URL of the attachment.\n    - `mime_type` (`Optional[str]`): The MIME type of the attachment.\n    - `name` (`Optional[str]`): The name of the attachment. Only populated when\n    the attachment originates from a file event in bot response, not from\n    post_message_attachment.\n    - `inline_ref` (`Optional[str]`): The inline reference of the attachment.\n    if post_message_attachment is called with is_inline=False, this will be None.\n\n    \"\"\"\n\n    attachment_url: Optional[str]\n    mime_type: Optional[str]\n    name: Optional[str] = None\n    inline_ref: Optional[str]\n\n    @classmethod\n    def from_dict(cls, data: dict[str, object]) -> \"AttachmentUploadResponse\":\n        \"\"\"Create an AttachmentUploadResponse from a dictionary (for aiohttp_poe FileEvent).\"\"\"\n        return cls(\n            attachment_url=data.get(\"url\"),  # type: ignore\n            mime_type=data.get(\"content_type\"),  # type: ignore\n            name=data.get(\"name\"),  # type: ignore\n            inline_ref=data.get(\"inline_ref\"),  # type: ignore\n        )\n\n\nclass AttachmentHttpResponse(BaseModel):\n    attachment_url: Optional[str]\n    mime_type: Optional[str]\n\n\nclass DataResponse(BaseModel):\n    \"\"\"\n\n    A response that contains arbitrary data to attach to the bot response.\n    This data can be retrieved in later requests to the bot within the same chat.\n    Note that only the final DataResponse object in the stream will be attached to the bot response.\n\n    #### Fields:\n    - `metadata` (`str`): String of data to attach to the bot response.\n\n    \"\"\"\n\n    model_config = ConfigDict(extra=\"forbid\")\n\n    metadata: str\n\n\nclass PartialResponse(BaseModel):\n    \"\"\"\n\n    Representation of a (possibly partial) response from a bot. Yield this in\n    `PoeBot.get_response` or `PoeBot.get_response_with_context` to communicate your response to Poe.\n\n    #### Fields:\n    - `text` (`str`): The actual text you want to display to the user. Note that this should solely\n    be the text in the next token since Poe will automatically concatenate all tokens before\n    displaying the response to the user.\n    - `data` (`Optional[dict[str, Any]]`): Used to send arbitrary json data to Poe. This is\n    currently only used for OpenAI function calling.\n    - `is_suggested_reply` (`bool = False`): Setting this to true will create a suggested reply with\n    the provided text value.\n    - `is_replace_response` (`bool = False`): Setting this to true will clear out the previously\n    displayed text to the user and replace it with the provided text value.\n\n    \"\"\"\n\n    # These objects are usually instantiated in user code, so we\n    # disallow extra fields to prevent mistakes.\n    model_config = ConfigDict(extra=\"forbid\")\n\n    text: str\n    \"\"\"Partial response text.\n\n    If the final bot response is \"ABC\", you may see a sequence\n    of PartialResponse objects like PartialResponse(text=\"A\"),\n    PartialResponse(text=\"B\"), PartialResponse(text=\"C\").\n\n    \"\"\"\n\n    data: Optional[dict[str, Any]] = None\n    \"\"\"Used when a bot returns the json event.\"\"\"\n\n    raw_response: object = None\n    \"\"\"For debugging, the raw response from the bot.\"\"\"\n\n    full_prompt: Optional[str] = None\n    \"\"\"For debugging, contains the full prompt as sent to the bot.\"\"\"\n\n    request_id: Optional[str] = None\n    \"\"\"May be set to an internal identifier for the request.\"\"\"\n\n    is_suggested_reply: bool = False\n    \"\"\"If true, this is a suggested reply.\"\"\"\n\n    is_replace_response: bool = False\n    \"\"\"If true, this text should completely replace the previous bot text.\"\"\"\n\n    attachment: Optional[Attachment] = None\n    \"\"\"If the bot returns an attachment, it will be contained here.\"\"\"\n\n    tool_calls: list[ToolCallDefinitionDelta] = Field(default_factory=list)\n    \"\"\"If the bot returns tool calls, it will be contained here.\"\"\"\n\n    index: Optional[int] = None\n    \"\"\"If a bot supports multiple responses, this is the index of the response to be updated.\"\"\"\n\n    @classmethod\n    def from_dict(cls, data: dict[str, object]) -> \"PartialResponse\":\n        \"\"\"Create a PartialResponse from a dictionary (for aiohttp_poe TextEvent).\"\"\"\n        return cls(text=str(data.get(\"text\", \"\")))\n\n\nclass ErrorResponse(PartialResponse):\n    \"\"\"\n\n    Similar to `PartialResponse`. Yield this to communicate errors from your bot.\n\n    #### Fields:\n    - `allow_retry` (`bool = True`): Whether or not to allow a user to retry on error.\n    - `error_type` (`Optional[ErrorType] = None`): An enum indicating what error to display.\n\n    \"\"\"\n\n    allow_retry: bool = True\n    error_type: Optional[ErrorType] = None\n\n    @classmethod\n    def from_dict(cls, data: dict[str, object]) -> \"ErrorResponse\":\n        \"\"\"Create an ErrorResponse from a dictionary (for aiohttp_poe ErrorEvent).\"\"\"\n        text = data.get(\"text\", \"\")\n        allow_retry = data.get(\"allow_retry\", True)\n        error_type_raw = data.get(\"error_type\")\n        error_type: Optional[ErrorType] = None\n        if isinstance(error_type_raw, str) and error_type_raw in get_args(ErrorType):\n            error_type = cast(ErrorType, error_type_raw)\n\n        return cls(text=str(text), allow_retry=bool(allow_retry), error_type=error_type)\n\n\nclass MetaResponse(PartialResponse):\n    \"\"\"\n\n    Similar to `Partial Response`. Yield this to communicate `meta` events from server bots.\n\n    #### Fields:\n    - `suggested_replies` (`bool = False`): Whether or not to enable suggested replies.\n    - `content_type` (`ContentType = \"text/markdown\"`): Used to describe the format of the response.\n    The currently supported values are `text/plain` and `text/markdown`.\n    - `refetch_settings` (`bool = False`): Used to trigger a settings fetch request from Poe. A more\n    robust way to trigger this is documented at:\n    https://creator.poe.com/docs/server-bots/updating-bot-settings\n\n    \"\"\"\n\n    linkify: bool = True  # deprecated\n    suggested_replies: bool = True\n    content_type: ContentType = \"text/markdown\"\n    refetch_settings: bool = False\n"
  },
  {
    "path": "tests/test_base.py",
    "content": "import json\nfrom collections.abc import AsyncIterable, AsyncIterator\nfrom contextlib import AbstractAsyncContextManager, asynccontextmanager\nfrom typing import Any, Callable, Union\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport httpx\nimport pytest\nfrom fastapi import Request\nfrom fastapi_poe.base import CostRequestError, InsufficientFundError, PoeBot, make_app\nfrom fastapi_poe.client import AttachmentUploadError\nfrom fastapi_poe.templates import (\n    IMAGE_VISION_ATTACHMENT_TEMPLATE,\n    TEXT_ATTACHMENT_TEMPLATE,\n    URL_ATTACHMENT_TEMPLATE,\n)\nfrom fastapi_poe.types import (\n    Attachment,\n    AttachmentUploadResponse,\n    CostItem,\n    DataResponse,\n    ErrorResponse,\n    MetaResponse,\n    PartialResponse,\n    ProtocolMessage,\n    QueryRequest,\n    RequestContext,\n    Sender,\n)\nfrom sse_starlette import ServerSentEvent\nfrom starlette.routing import Route\n\n\n@pytest.fixture\ndef basic_bot() -> PoeBot:\n    mock_bot = PoeBot(path=\"/bot/test_bot\", bot_name=\"test_bot\", access_key=\"123\")\n\n    async def get_response(\n        request: QueryRequest,\n    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:\n        yield MetaResponse(\n            text=\"\",\n            suggested_replies=True,\n            content_type=\"text/markdown\",\n            refetch_settings=False,\n        )\n        yield PartialResponse(text=\"hello\")\n        yield PartialResponse(text=\"this is a suggested reply\", is_suggested_reply=True)\n        yield PartialResponse(\n            text=\"this is a replace response\", is_replace_response=True\n        )\n        yield DataResponse(metadata='{\"foo\": \"bar\"}')\n\n    mock_bot.get_response = get_response\n    return mock_bot\n\n\n@pytest.fixture\ndef attachment_bot() -> PoeBot:\n    mock_bot = PoeBot(\n        path=\"/bot/attachment_bot\", bot_name=\"attachment_bot\", access_key=\"123\"\n    )\n\n    async def get_response(\n        request: QueryRequest,\n    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:\n        yield PartialResponse(text=\"Generating... (1s elapsed)\")\n        yield PartialResponse(\n            text=\"Generating... (2s elapsed)\", is_replace_response=True\n        )\n        yield PartialResponse(\n            text=\"Generating... (3s elapsed)\", is_replace_response=True\n        )\n        inline_ref = \"abc\"\n        yield PartialResponse(\n            text=f\"![image][{inline_ref}]\",\n            attachment=Attachment(\n                url=\"https://pfst.cf2.poecdn.net/base/image/cat.jpg\",\n                name=\"cat.jpg\",\n                content_type=\"image/jpeg\",\n                inline_ref=inline_ref,\n            ),\n            is_replace_response=True,\n        )\n        # test a non-inline attachment\n        yield PartialResponse(\n            text=\"\",\n            attachment=Attachment(\n                url=\"https://pfst.cf2.poecdn.net/base/application/test.pdf\",\n                name=\"test.pdf\",\n                content_type=\"application/pdf\",\n            ),\n        )\n\n    mock_bot.get_response = get_response\n    return mock_bot\n\n\n@pytest.fixture\ndef error_bot() -> PoeBot:\n    mock_bot = PoeBot(path=\"/bot/error_bot\", bot_name=\"error_bot\", access_key=\"123\")\n\n    async def get_response(\n        request: QueryRequest,\n    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:\n        yield PartialResponse(text=\"hello\")\n        yield ErrorResponse(text=\"sample error\", allow_retry=True)\n\n    mock_bot.get_response = get_response\n    return mock_bot\n\n\n@pytest.fixture\ndef mock_request() -> QueryRequest:\n    return QueryRequest(\n        version=\"1.0\",\n        type=\"query\",\n        query=[ProtocolMessage(role=\"user\", content=\"Hello, world!\", sender=Sender())],\n        user_id=\"123\",\n        conversation_id=\"123\",\n        message_id=\"456\",\n        bot_query_id=\"123\",\n    )\n\n\n@pytest.fixture\ndef mock_request_context() -> RequestContext:\n    return RequestContext(http_request=Mock(spec=Request))\n\n\nclass TestPoeBot:\n\n    @pytest.mark.asyncio\n    async def test_handle_query_basic_bot(\n        self,\n        basic_bot: PoeBot,\n        mock_request: QueryRequest,\n        mock_request_context: RequestContext,\n    ) -> None:\n        expected_sse_events = [\n            ServerSentEvent(\n                event=\"meta\",\n                data=json.dumps(\n                    {\n                        \"suggested_replies\": True,\n                        \"content_type\": \"text/markdown\",\n                        \"refetch_settings\": False,\n                        \"linkify\": True,\n                    }\n                ),\n            ),\n            ServerSentEvent(event=\"text\", data=json.dumps({\"text\": \"hello\"})),\n            ServerSentEvent(\n                event=\"suggested_reply\",\n                data=json.dumps({\"text\": \"this is a suggested reply\"}),\n            ),\n            ServerSentEvent(\n                event=\"replace_response\",\n                data=json.dumps({\"text\": \"this is a replace response\"}),\n            ),\n            ServerSentEvent(\n                event=\"data\", data=json.dumps({\"metadata\": '{\"foo\": \"bar\"}'})\n            ),\n            ServerSentEvent(event=\"done\", data=\"{}\"),\n        ]\n        actual_sse_events = [\n            event\n            async for event in basic_bot.handle_query(\n                mock_request, mock_request_context\n            )\n        ]\n        assert len(actual_sse_events) == len(expected_sse_events)\n\n        for actual_event, expected_event in zip(actual_sse_events, expected_sse_events):\n            assert actual_event.event == expected_event.event\n            assert expected_event.data and actual_event.data\n            assert json.loads(actual_event.data) == json.loads(expected_event.data)\n\n    @pytest.mark.asyncio\n    async def test_handle_query_error_bot(\n        self,\n        error_bot: PoeBot,\n        mock_request: QueryRequest,\n        mock_request_context: RequestContext,\n    ) -> None:\n        expected_sse_events_error = [\n            ServerSentEvent(event=\"text\", data=json.dumps({\"text\": \"hello\"})),\n            ServerSentEvent(\n                event=\"error\",\n                data=json.dumps({\"text\": \"sample error\", \"allow_retry\": True}),\n            ),\n            ServerSentEvent(event=\"done\", data=\"{}\"),\n        ]\n        actual_sse_events = [\n            event\n            async for event in error_bot.handle_query(\n                mock_request, mock_request_context\n            )\n        ]\n        assert len(actual_sse_events) == len(expected_sse_events_error)\n\n        for actual_event, expected_event in zip(\n            actual_sse_events, expected_sse_events_error\n        ):\n            assert actual_event.event == expected_event.event\n            assert expected_event.data and actual_event.data\n            assert json.loads(actual_event.data) == json.loads(expected_event.data)\n\n    def test_insert_attachment_messages(self, basic_bot: PoeBot) -> None:\n        # Create mock attachments\n        mock_text_attachment = Attachment(\n            url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n            name=\"test.txt\",\n            content_type=\"text/plain\",\n            parsed_content=\"Hello, world!\",\n        )\n        mock_image_attachment = Attachment(\n            url=\"https://pfst.cf2.poecdn.net/base/image/test.png\",\n            name=\"test.png\",\n            content_type=\"image/png\",\n            parsed_content=\"test.png***Hello, world!\",\n        )\n        mock_image_attachment_2 = Attachment(\n            url=\"https://pfst.cf2.poecdn.net/base/image/test.png\",\n            name=\"testimage2.jpg\",\n            content_type=\"image/jpeg\",\n            parsed_content=\"Hello, world!\",\n        )\n        mock_pdf_attachment = Attachment(\n            url=\"https://pfst.cf2.poecdn.net/base/application/test.pdf\",\n            name=\"test.pdf\",\n            content_type=\"application/pdf\",\n            parsed_content=\"Hello, world!\",\n        )\n        mock_html_attachment = Attachment(\n            url=\"https://pfst.cf2.poecdn.net/base/text/test.html\",\n            name=\"test.html\",\n            content_type=\"text/html\",\n            parsed_content=\"Hello, world!\",\n        )\n        mock_video_attachment = Attachment(\n            url=\"https://pfst.cf2.poecdn.net/base/video/test.mp4\",\n            name=\"test.mp4\",\n            content_type=\"video/mp4\",\n            parsed_content=\"Hello, world!\",\n        )\n        # Create mock protocol messages\n        message_without_attachments = ProtocolMessage(\n            role=\"user\", content=\"Hello, world!\", sender=Sender()\n        )\n        message_with_attachments = ProtocolMessage(\n            role=\"user\",\n            content=\"Here's some attachments\",\n            sender=Sender(),\n            attachments=[\n                mock_text_attachment,\n                mock_image_attachment,\n                mock_image_attachment_2,\n                mock_pdf_attachment,\n                mock_html_attachment,\n                mock_video_attachment,\n            ],\n        )\n        # Create mock query request\n        mock_query_request = QueryRequest(\n            version=\"1.0\",\n            type=\"query\",\n            query=[message_without_attachments, message_with_attachments],\n            user_id=\"123\",\n            conversation_id=\"123\",\n            message_id=\"456\",\n        )\n\n        assert (\n            mock_image_attachment.parsed_content\n        )  # satisfy pyright so split() works below\n        expected_protocol_messages = [\n            message_without_attachments,\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=TEXT_ATTACHMENT_TEMPLATE.format(\n                    attachment_name=mock_text_attachment.name,\n                    attachment_parsed_content=mock_text_attachment.parsed_content,\n                ),\n            ),\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=TEXT_ATTACHMENT_TEMPLATE.format(\n                    attachment_name=mock_pdf_attachment.name,\n                    attachment_parsed_content=mock_pdf_attachment.parsed_content,\n                ),\n            ),\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=URL_ATTACHMENT_TEMPLATE.format(\n                    attachment_name=mock_html_attachment.name,\n                    content=mock_html_attachment.parsed_content,\n                ),\n            ),\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=IMAGE_VISION_ATTACHMENT_TEMPLATE.format(\n                    filename=mock_image_attachment.parsed_content.split(\"***\")[0],\n                    parsed_image_description=mock_image_attachment.parsed_content.split(\n                        \"***\"\n                    )[1],\n                ),\n            ),\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=IMAGE_VISION_ATTACHMENT_TEMPLATE.format(\n                    filename=mock_image_attachment_2.name,\n                    parsed_image_description=mock_image_attachment_2.parsed_content,\n                ),\n            ),\n            message_with_attachments,\n        ]\n\n        modified_query_request = basic_bot.insert_attachment_messages(\n            mock_query_request\n        )\n        protocol_messages = modified_query_request.query\n\n        assert protocol_messages == expected_protocol_messages\n\n    def test_make_prompt_author_role_alternated(self, basic_bot: PoeBot) -> None:\n        mock_protocol_messages = [\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=\"Hello, world!\",\n                attachments=[\n                    Attachment(\n                        url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                        name=\"test.txt\",\n                        content_type=\"text/plain\",\n                        parsed_content=\"Hello, world!\",\n                    )\n                ],\n            ),\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=\"Hello, world!\",\n                attachments=[\n                    Attachment(\n                        url=\"https://pfst.cf2.poecdn.net/base/text/test2.txt\",\n                        name=\"test2.txt\",\n                        content_type=\"text/plain\",\n                        parsed_content=\"Bye!\",\n                    )\n                ],\n            ),\n            ProtocolMessage(role=\"bot\", sender=Sender(), content=\"Hello, world!\"),\n        ]\n        expected_protocol_messages = [\n            ProtocolMessage(\n                role=\"user\",\n                sender=Sender(),\n                content=\"Hello, world!\\n\\nHello, world!\",\n                attachments=[\n                    Attachment(\n                        url=\"https://pfst.cf2.poecdn.net/base/text/test2.txt\",\n                        name=\"test2.txt\",\n                        content_type=\"text/plain\",\n                        parsed_content=\"Bye!\",\n                    ),\n                    Attachment(\n                        url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                        name=\"test.txt\",\n                        content_type=\"text/plain\",\n                        parsed_content=\"Hello, world!\",\n                    ),\n                ],\n            ),\n            ProtocolMessage(role=\"bot\", sender=Sender(), content=\"Hello, world!\"),\n        ]\n        assert (\n            basic_bot.make_prompt_author_role_alternated(mock_protocol_messages)\n            == expected_protocol_messages\n        )\n\n\nclass TestPoeBotWithAttachments:\n\n    @pytest.mark.asyncio\n    async def test_handle_query_attachment_bot_basic(\n        self,\n        attachment_bot: PoeBot,\n        mock_request: QueryRequest,\n        mock_request_context: RequestContext,\n    ) -> None:\n        expected_sse_events = [\n            ServerSentEvent(\n                event=\"text\", data=json.dumps({\"text\": \"Generating... (1s elapsed)\"})\n            ),\n            ServerSentEvent(\n                event=\"replace_response\",\n                data=json.dumps({\"text\": \"Generating... (2s elapsed)\"}),\n            ),\n            ServerSentEvent(\n                event=\"replace_response\",\n                data=json.dumps({\"text\": \"Generating... (3s elapsed)\"}),\n            ),\n            ServerSentEvent(\n                event=\"file\",\n                data=json.dumps(\n                    {\n                        \"url\": \"https://pfst.cf2.poecdn.net/base/image/cat.jpg\",\n                        \"content_type\": \"image/jpeg\",\n                        \"name\": \"cat.jpg\",\n                        \"inline_ref\": \"abc\",\n                    }\n                ),\n            ),\n            ServerSentEvent(\n                event=\"replace_response\", data=json.dumps({\"text\": \"![image][abc]\"})\n            ),\n            ServerSentEvent(\n                event=\"file\",\n                data=json.dumps(\n                    {\n                        \"url\": \"https://pfst.cf2.poecdn.net/base/application/test.pdf\",\n                        \"content_type\": \"application/pdf\",\n                        \"name\": \"test.pdf\",\n                        \"inline_ref\": None,\n                    }\n                ),\n            ),\n            ServerSentEvent(event=\"text\", data=json.dumps({\"text\": \"\"})),\n            ServerSentEvent(event=\"done\", data=\"{}\"),\n        ]\n        actual_sse_events = [\n            event\n            async for event in attachment_bot.handle_query(\n                mock_request, mock_request_context\n            )\n        ]\n        assert len(actual_sse_events) == len(expected_sse_events)\n\n        for actual_event, expected_event in zip(actual_sse_events, expected_sse_events):\n            assert actual_event.event == expected_event.event\n            assert expected_event.data and actual_event.data\n            assert json.loads(actual_event.data) == json.loads(expected_event.data)\n\n\nclass TestPostMessageAttachment:\n\n    @pytest.mark.asyncio\n    @patch(\"httpx.AsyncClient.send\")\n    async def test_post_message_attachment_basic(\n        self, mock_send: Mock, basic_bot: PoeBot\n    ) -> None:\n        mock_send.return_value = httpx.Response(\n            200,\n            json={\n                \"attachment_url\": \"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                \"mime_type\": \"text/plain\",\n            },\n        )\n\n        result = await basic_bot.post_message_attachment(\n            message_id=\"123\",\n            download_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n            download_filename=\"test.txt\",\n        )\n\n        assert result == AttachmentUploadResponse(\n            inline_ref=None,\n            attachment_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n            mime_type=\"text/plain\",\n        )\n        file_events_to_yield = basic_bot._file_events_to_yield.get(\"123\", [])\n        assert len(file_events_to_yield) == 1\n        assert file_events_to_yield.pop().data == json.dumps(\n            {\n                \"url\": \"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                \"content_type\": \"text/plain\",\n                \"name\": \"test.txt\",\n                \"inline_ref\": None,\n            }\n        )\n\n    @pytest.mark.asyncio\n    @patch(\"httpx.AsyncClient.send\")\n    async def test_post_message_attachment_download_url(\n        self, mock_send: Mock, basic_bot: PoeBot\n    ) -> None:\n        mock_send.return_value = httpx.Response(\n            200,\n            json={\n                \"attachment_url\": \"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                \"mime_type\": \"text/plain\",\n            },\n        )\n\n        result = await basic_bot.post_message_attachment(\n            message_id=\"123\",\n            download_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n        )\n\n        assert result == AttachmentUploadResponse(\n            inline_ref=None,\n            attachment_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n            mime_type=\"text/plain\",\n        )\n        file_events_to_yield = basic_bot._file_events_to_yield.get(\"123\", [])\n        assert len(file_events_to_yield) == 1\n        assert file_events_to_yield.pop().data == json.dumps(\n            {\n                \"url\": \"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                \"content_type\": \"text/plain\",\n                \"name\": \"test.txt\",  # extracted from url\n                \"inline_ref\": None,\n            }\n        )\n\n    @pytest.mark.asyncio\n    @patch(\"httpx.AsyncClient.send\")\n    @patch(\"fastapi_poe.base.generate_inline_ref\")\n    async def test_post_message_attachment_inline(\n        self, mock_generate_inline_ref: Mock, mock_send: Mock, basic_bot: PoeBot\n    ) -> None:\n        mock_send.return_value = httpx.Response(\n            200,\n            json={\n                \"attachment_url\": \"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                \"mime_type\": \"text/plain\",\n            },\n        )\n        mock_generate_inline_ref.return_value = \"ab32ef21\"\n\n        result = await basic_bot.post_message_attachment(\n            message_id=\"123\",\n            download_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n            download_filename=\"test.txt\",\n            is_inline=True,\n        )\n\n        assert result == AttachmentUploadResponse(\n            inline_ref=\"ab32ef21\",\n            attachment_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n            mime_type=\"text/plain\",\n        )\n\n        # Add a second attachment\n        mock_send.return_value = httpx.Response(\n            200,\n            json={\n                \"attachment_url\": \"https://pfst.cf2.poecdn.net/base/image/test.png\",\n                \"mime_type\": \"image/png\",\n            },\n        )\n\n        result = await basic_bot.post_message_attachment(\n            message_id=\"123\",\n            download_url=\"https://pfst.cf2.poecdn.net/base/image/test.png\",\n            download_filename=\"test.png\",\n            is_inline=False,\n        )\n\n        assert result == AttachmentUploadResponse(\n            inline_ref=None,\n            attachment_url=\"https://pfst.cf2.poecdn.net/base/image/test.png\",\n            mime_type=\"image/png\",\n        )\n        # Check that the file events are added to the instance dictionary\n        file_events_to_yield = basic_bot._file_events_to_yield.get(\"123\", [])\n        assert len(file_events_to_yield) == 2\n        expected_items = [\n            {\n                \"url\": \"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                \"content_type\": \"text/plain\",\n                \"name\": \"test.txt\",\n                \"inline_ref\": \"ab32ef21\",\n            },\n            {\n                \"url\": \"https://pfst.cf2.poecdn.net/base/image/test.png\",\n                \"content_type\": \"image/png\",\n                \"name\": \"test.png\",\n                \"inline_ref\": None,\n            },\n        ]\n        expected_items_json = {json.dumps(item) for item in expected_items}\n        actual_items_json = {file_event.data for file_event in file_events_to_yield}\n        assert expected_items_json == actual_items_json\n\n    @pytest.mark.asyncio\n    @patch(\"httpx.AsyncClient.send\")\n    async def test_post_message_attachment_error(\n        self, mock_send: Mock, basic_bot: PoeBot\n    ) -> None:\n        mock_send.return_value = httpx.Response(400, json={\"error\": \"test\"})\n        with pytest.raises(AttachmentUploadError):\n            await basic_bot.post_message_attachment(\n                message_id=\"123\",\n                download_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                download_filename=\"test.txt\",\n            )\n\n        with pytest.raises(ValueError):\n            await basic_bot.post_message_attachment(\n                message_id=\"123\",\n                download_url=\"https://pfst.cf2.poecdn.net/base/text/test.txt\",\n                download_filename=\"test.txt\",\n                file_data=b\"test\",\n                filename=\"test.txt\",\n            )\n\n\nclass TestCostCapture:\n\n    def create_sse_mock(\n        self,\n        events: list[ServerSentEvent],\n        status_code: int = 200,\n        reason_phrase: str = \"OK\",\n    ) -> Callable[..., AbstractAsyncContextManager[AsyncMock]]:\n        @asynccontextmanager\n        async def mock_sse_connection(\n            *args: Any, **kwargs: Any  # noqa: ANN401\n        ) -> AsyncIterator[AsyncMock]:\n            mock_source = AsyncMock()\n            mock_source.response.status_code = status_code\n            mock_source.response.reason_phrase = reason_phrase\n\n            async def mock_aiter_sse() -> AsyncIterator[ServerSentEvent]:\n                for event in events:\n                    yield event\n\n            mock_source.aiter_sse = mock_aiter_sse\n            yield mock_source\n\n        return mock_sse_connection\n\n    @pytest.mark.asyncio\n    async def test_authorize_cost_success(\n        self, basic_bot: PoeBot, mock_request: QueryRequest\n    ) -> None:\n        cost_item = CostItem(amount_usd_milli_cents=1000)\n        url = \"https://example.com\"\n\n        events = [ServerSentEvent(event=\"result\", data='{\"status\": \"success\"}')]\n\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(\n                events, status_code=200, reason_phrase=\"OK\"\n            )\n            await basic_bot.authorize_cost(\n                request=mock_request, amounts=cost_item, base_url=url\n            )\n\n            mock_connect_sse.assert_called_once()\n            call_args = mock_connect_sse.call_args\n            assert (\n                call_args.kwargs[\"url\"]\n                == f\"{url}bot/cost/{mock_request.bot_query_id}/authorize\"\n            )\n            assert call_args.kwargs[\"json\"][\"amounts\"] == [cost_item.model_dump()]\n            assert call_args.kwargs[\"json\"][\"access_key\"] == basic_bot.access_key\n\n    @pytest.mark.asyncio\n    async def test_authorize_cost_failure(\n        self, basic_bot: PoeBot, mock_request: QueryRequest\n    ) -> None:\n        cost_item = CostItem(amount_usd_milli_cents=1000)\n        url = \"https://example.com\"\n\n        events = [\n            ServerSentEvent(event=\"result\", data='{\"status\": \"insufficient funds\"}')\n        ]\n\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(\n                events, status_code=400, reason_phrase=\"Bad Request\"\n            )\n            with pytest.raises(CostRequestError):\n                await basic_bot.authorize_cost(\n                    request=mock_request, amounts=cost_item, base_url=url\n                )\n\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(\n                events, status_code=200, reason_phrase=\"OK\"\n            )\n            with pytest.raises(InsufficientFundError):\n                await basic_bot.authorize_cost(\n                    request=mock_request, amounts=cost_item, base_url=url\n                )\n\n    @pytest.mark.asyncio\n    async def test_capture_cost_success(\n        self, basic_bot: PoeBot, mock_request: QueryRequest\n    ) -> None:\n        cost_item = CostItem(amount_usd_milli_cents=1000)\n        url = \"https://example.com\"\n\n        events = [ServerSentEvent(event=\"result\", data='{\"status\": \"success\"}')]\n\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(\n                events, status_code=200, reason_phrase=\"OK\"\n            )\n            await basic_bot.capture_cost(\n                request=mock_request, amounts=cost_item, base_url=url\n            )\n\n            mock_connect_sse.assert_called_once()\n            call_args = mock_connect_sse.call_args\n            assert (\n                call_args.kwargs[\"url\"]\n                == f\"{url}bot/cost/{mock_request.bot_query_id}/capture\"\n            )\n            assert call_args.kwargs[\"json\"][\"amounts\"] == [cost_item.model_dump()]\n            assert call_args.kwargs[\"json\"][\"access_key\"] == basic_bot.access_key\n\n    @pytest.mark.asyncio\n    async def test_capture_cost_failure(\n        self, basic_bot: PoeBot, mock_request: QueryRequest\n    ) -> None:\n        cost_item = CostItem(amount_usd_milli_cents=1000)\n        url = \"https://example.com\"\n\n        events = [\n            ServerSentEvent(event=\"result\", data='{\"status\": \"insufficient funds\"}')\n        ]\n\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(\n                events, status_code=400, reason_phrase=\"Bad Request\"\n            )\n            with pytest.raises(CostRequestError):\n                await basic_bot.capture_cost(\n                    request=mock_request, amounts=cost_item, base_url=url\n                )\n\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(\n                events, status_code=200, reason_phrase=\"OK\"\n            )\n            with pytest.raises(InsufficientFundError):\n                await basic_bot.capture_cost(\n                    request=mock_request, amounts=cost_item, base_url=url\n                )\n\n\ndef test_make_app(basic_bot: PoeBot, error_bot: PoeBot) -> None:\n    app = make_app([basic_bot, error_bot])\n    assert app is not None\n    assert app.router is not None\n\n    expected_routes = [\n        {\"path\": \"/bot/error_bot\", \"name\": \"poe_post\", \"methods\": {\"POST\"}},\n        {\"path\": \"/bot/test_bot\", \"name\": \"poe_post\", \"methods\": {\"POST\"}},\n    ]\n\n    routes = [route for route in app.router.routes if isinstance(route, Route)]\n\n    for expected in expected_routes:\n        route_exists = any(\n            route.path == expected[\"path\"]\n            and route.name == expected[\"name\"]\n            and route.methods == expected[\"methods\"]\n            for route in routes\n        )\n\n        assert route_exists, f\"Route not found: {expected}\"\n"
  },
  {
    "path": "tests/test_client.py",
    "content": "import json\nfrom collections.abc import AsyncGenerator, AsyncIterator, Awaitable\nfrom contextlib import AbstractAsyncContextManager, asynccontextmanager\nfrom typing import Any, Callable, cast\nfrom unittest.mock import ANY, AsyncMock, Mock, patch\n\nimport httpx\nimport pytest\nimport pytest_asyncio\nfrom fastapi_poe.client import (\n    AttachmentUploadError,\n    BotError,\n    BotErrorNoRetry,\n    _BotContext,\n    _safe_ellipsis,\n    get_bot_response,\n    get_bot_response_sync,\n    get_final_response,\n    stream_request,\n    sync_bot_settings,\n    upload_file,\n)\nfrom fastapi_poe.types import (\n    FunctionCallDefinition,\n    ProtocolMessage,\n    QueryRequest,\n    Sender,\n    ToolCallDefinition,\n    ToolDefinition,\n    ToolResultDefinition,\n)\nfrom fastapi_poe.types import MetaResponse as MetaMessage\nfrom fastapi_poe.types import PartialResponse as BotMessage\nfrom sse_starlette import ServerSentEvent\n\n\n@pytest.fixture\ndef mock_request() -> QueryRequest:\n    return QueryRequest(\n        version=\"1.2\",\n        type=\"query\",\n        query=[ProtocolMessage(role=\"user\", content=\"Hello, world!\", sender=Sender())],\n        user_id=\"123\",\n        conversation_id=\"456\",\n        message_id=\"789\",\n    )\n\n\nasync def message_generator() -> AsyncGenerator[BotMessage, None]:\n    return_messages = [\"Hello,\", \" world\", \"!\"]\n    for message in return_messages:\n        yield BotMessage(text=message)\n\n\n@pytest_asyncio.fixture\nasync def mock_text_only_query_response() -> AsyncGenerator:\n    yield message_generator()\n\n\n@pytest.mark.asyncio\nclass TestStreamRequest:\n\n    @pytest.fixture\n    def tool_definitions_and_executables(\n        self,\n    ) -> tuple[list[ToolDefinition], list[Callable]]:\n        def get_current_weather(location: str, unit: str = \"fahrenheit\") -> str:\n            \"\"\"Get the current weather in a given location\"\"\"\n            if \"tokyo\" in location.lower():\n                return json.dumps(\n                    {\"location\": \"Tokyo\", \"temperature\": \"11\", \"unit\": unit}\n                )\n            elif \"san francisco\" in location.lower():\n                return json.dumps(\n                    {\"location\": \"San Francisco\", \"temperature\": \"72\", \"unit\": unit}\n                )\n            elif \"paris\" in location.lower():\n                return json.dumps(\n                    {\"location\": \"Paris\", \"temperature\": \"22\", \"unit\": unit}\n                )\n            else:\n                return json.dumps({\"location\": location, \"temperature\": \"unknown\"})\n\n        def get_current_mayor(location: str) -> str:\n            \"\"\"Get the current mayor of a given location.\"\"\"\n            if \"tokyo\" in location.lower():\n                return json.dumps({\"location\": \"Tokyo\", \"mayor\": \"Yuriko Koike\"})\n            elif \"san francisco\" in location.lower():\n                return json.dumps(\n                    {\"location\": \"San Francisco\", \"mayor\": \"London Breed\"}\n                )\n            elif \"paris\" in location.lower():\n                return json.dumps({\"location\": \"Paris\", \"mayor\": \"Anne Hidalgo\"})\n            else:\n                return json.dumps({\"location\": location, \"mayor\": \"unknown\"})\n\n        mock_tool_dict_list = [\n            {\n                \"type\": \"function\",\n                \"function\": {\n                    \"name\": \"get_current_weather\",\n                    \"description\": \"Get the current weather in a given location\",\n                    \"parameters\": {\n                        \"type\": \"object\",\n                        \"properties\": {\n                            \"location\": {\n                                \"type\": \"string\",\n                                \"description\": \"The city and state, e.g. San Francisco, CA\",\n                            },\n                            \"unit\": {\n                                \"type\": \"string\",\n                                \"enum\": [\"celsius\", \"fahrenheit\"],\n                            },\n                        },\n                        \"required\": [\"location\"],\n                    },\n                },\n            },\n            {\n                \"type\": \"function\",\n                \"function\": {\n                    \"name\": \"get_current_mayor\",\n                    \"description\": \"Get the current mayor of a given location.\",\n                    \"parameters\": {\n                        \"type\": \"object\",\n                        \"properties\": {\n                            \"location\": {\n                                \"type\": \"string\",\n                                \"description\": \"The city and state, e.g. San Francisco, CA\",\n                            }\n                        },\n                        \"required\": [\"location\"],\n                    },\n                },\n            },\n        ]\n\n        tools = [ToolDefinition(**tool_dict) for tool_dict in mock_tool_dict_list]\n        tool_executables = [get_current_weather, get_current_mayor]\n\n        return tools, tool_executables\n\n    def _create_mock_openai_response(self, delta: dict[str, Any]) -> dict[str, Any]:\n        mock_tool_response_template = {\n            \"id\": \"chatcmpl-abcde\",\n            \"object\": \"chat.completion.chunk\",\n            \"created\": 1738799163,\n            \"model\": \"gpt-3.5-turbo-0125\",\n            \"service_tier\": \"default\",\n            \"system_fingerprint\": None,\n            \"choices\": [\n                {\n                    \"index\": 0,\n                    \"delta\": {\n                        \"role\": \"assistant\",\n                        \"content\": None,\n                        \"tool_calls\": None,\n                        \"refusal\": None,\n                    },\n                    \"logprobs\": None,\n                    \"finish_reason\": None,\n                }\n            ],\n            \"usage\": None,\n        }\n\n        mock_tool_response_template[\"choices\"][0][\"delta\"] = delta\n        return mock_tool_response_template\n\n    async def mock_perform_query_request_for_tools(\n        self,\n    ) -> AsyncGenerator[BotMessage, None]:\n        \"\"\"Mock the OpenAI API response for tool calls.\"\"\"\n\n        # See https://platform.openai.com/docs/guides/function-calling?api-mode=chat#streaming\n        # for details on OpenAI's streaming tool call format.\n        mock_delta = [\n            {\n                \"tool_calls\": [\n                    {\n                        \"index\": 0,\n                        \"id\": \"call_123\",\n                        \"type\": \"function\",\n                        \"function\": {\"name\": \"get_current_weather\", \"arguments\": \"\"},\n                    }\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": '{\"'}}]},\n            {\n                \"tool_calls\": [\n                    {\n                        \"index\": 0,\n                        \"function\": {\"arguments\": 'location\":\"San Francisco, CA'},\n                    }\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": '\"}'}}]},\n            {\n                \"tool_calls\": [\n                    {\n                        \"index\": 1,\n                        \"id\": \"call_456\",\n                        \"type\": \"function\",\n                        \"function\": {\"name\": \"get_current_mayor\", \"arguments\": \"\"},\n                    }\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 1, \"function\": {\"arguments\": '{\"'}}]},\n            {\n                \"tool_calls\": [\n                    {\"index\": 1, \"function\": {\"arguments\": 'location\":\"Tokyo, JP'}}\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 1, \"function\": {\"arguments\": '\"}'}}]},\n            {},\n        ]\n        mock_responses = [\n            self._create_mock_openai_response(delta) for delta in mock_delta\n        ]\n        # last chunk has finish reason \"tool_calls\"\n        mock_responses[-1][\"choices\"][0][\"finish_reason\"] = \"tool_calls\"\n\n        return_values = [\n            BotMessage(text=\"\", data=response) for response in mock_responses\n        ]\n\n        for message in return_values:\n            yield message\n\n    async def mock_perform_query_request_for_tools_missing_first_delta_for_index(\n        self,\n    ) -> AsyncGenerator[BotMessage, None]:\n        \"\"\"Mock the OpenAI API response for tool calls.\"\"\"\n\n        # See https://platform.openai.com/docs/guides/function-calling?api-mode=chat#streaming\n        # for details on OpenAI's streaming tool call format.\n        mock_delta = [\n            # Missing the first delta for index 0, where the tool call id, type, and function name\n            # are expected.\n            {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": '{\"'}}]},\n            {\n                \"tool_calls\": [\n                    {\n                        \"index\": 0,\n                        \"function\": {\"arguments\": 'location\":\"San Francisco, CA'},\n                    }\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": '\"}'}}]},\n            {\n                \"tool_calls\": [\n                    {\n                        \"index\": 1,\n                        \"id\": \"call_456\",\n                        \"type\": \"function\",\n                        \"function\": {\"name\": \"get_current_mayor\", \"arguments\": \"\"},\n                    }\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 1, \"function\": {\"arguments\": '{\"'}}]},\n            {\n                \"tool_calls\": [\n                    {\"index\": 1, \"function\": {\"arguments\": 'location\":\"Tokyo, JP'}}\n                ]\n            },\n            {\"tool_calls\": [{\"index\": 1, \"function\": {\"arguments\": '\"}'}}]},\n            {},\n        ]\n        mock_responses = [\n            self._create_mock_openai_response(delta) for delta in mock_delta\n        ]\n        # last chunk has finish reason \"tool_calls\"\n        mock_responses[-1][\"choices\"][0][\"finish_reason\"] = \"tool_calls\"\n\n        return_values = [\n            BotMessage(text=\"\", data=response) for response in mock_responses\n        ]\n\n        for message in return_values:\n            yield message\n\n    async def mock_perform_query_request_with_no_tools_selected(\n        self,\n    ) -> AsyncGenerator[BotMessage, None]:\n        \"\"\"Mock the OpenAI API response for tool calls when no tools are selected.\"\"\"\n\n        mock_deltas = [\n            {\"content\": \"there were\"},\n            {\"content\": \" no tool calls\"},\n            {\"content\": \"!\"},\n            {},\n        ]\n        mock_responses = [\n            self._create_mock_openai_response(delta) for delta in mock_deltas\n        ]\n        # last chunk has no choices array because it sends usage\n        mock_responses[-1][\"choices\"] = []\n        mock_responses[-1][\"usage\"] = {\"completion_tokens\": 1, \"prompt_tokens\": 1}\n        return_values = [\n            BotMessage(text=\"\", data=response) for response in mock_responses\n        ]\n        for message in return_values:\n            yield message\n\n    async def mock_perform_query_request_with_bot_returning_regular_response(\n        self,\n    ) -> AsyncGenerator[BotMessage, None]:\n        \"\"\"Mock the OpenAI API response for tool calls when the bot returns a regular response.\"\"\"\n        yield BotMessage(text=\"here \")\n        yield BotMessage(text=\"is \")\n        yield BotMessage(text=\"the \")\n        yield BotMessage(text=\"final \")\n        yield BotMessage(text=\"response\")\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_basic(\n        self,\n        mock_perform_query_request: Mock,\n        mock_request: QueryRequest,\n        mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n    ) -> None:\n        mock_perform_query_request.return_value = mock_text_only_query_response\n        concatenated_text = \"\"\n        async for message in stream_request(mock_request, \"test_bot\"):\n            concatenated_text += message.text\n        assert concatenated_text == \"Hello, world!\"\n\n    @patch(\"fastapi_poe.client._BotContext\")\n    async def test_stream_request_with_extra_headers(\n        self, mock_bot_context: Mock, mock_request: QueryRequest\n    ) -> None:\n        async for _ in stream_request(\n            mock_request, \"test_bot\", extra_headers={\"X-Test\": \"test\"}\n        ):\n            pass\n\n        mock_bot_context.assert_called_once_with(\n            endpoint=ANY,\n            session=ANY,\n            api_key=ANY,\n            on_error=ANY,\n            extra_headers={\"X-Test\": \"test\"},\n        )\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n        mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n    ) -> None:\n        mock_perform_query_request_with_tools.side_effect = [\n            self.mock_perform_query_request_for_tools(),\n            mock_text_only_query_response,\n        ]\n        tools, _ = tool_definitions_and_executables\n\n        aggregated_tool_calls: dict[int, ToolCallDefinition] = {}\n        async for message in stream_request(mock_request, \"test_bot\", tools=tools):\n            if message.tool_calls:\n                for tool_call in message.tool_calls:\n                    # Use the index to aggregate the tool call chunks\n                    if tool_call.index not in aggregated_tool_calls:\n                        aggregated_tool_calls[tool_call.index] = ToolCallDefinition(\n                            id=tool_call.id or \"\",\n                            type=tool_call.type or \"\",\n                            function=FunctionCallDefinition(\n                                name=tool_call.function.name or \"\",\n                                arguments=tool_call.function.arguments,\n                            ),\n                        )\n                    else:\n                        aggregated_tool_calls[\n                            tool_call.index\n                        ].function.arguments += tool_call.function.arguments\n\n        expected_tool_calls = [\n            ToolCallDefinition(\n                id=\"call_123\",\n                type=\"function\",\n                function=FunctionCallDefinition(\n                    name=\"get_current_weather\",\n                    arguments='{\"location\":\"San Francisco, CA\"}',\n                ),\n            ),\n            ToolCallDefinition(\n                id=\"call_456\",\n                type=\"function\",\n                function=FunctionCallDefinition(\n                    name=\"get_current_mayor\", arguments='{\"location\":\"Tokyo, JP\"}'\n                ),\n            ),\n        ]\n        assert list(aggregated_tool_calls.values()) == expected_tool_calls\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools_when_no_tools_selected(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n    ) -> None:\n        \"\"\"Test case where the model does not select any tools to call.\"\"\"\n        mock_perform_query_request_with_tools.side_effect = [\n            self.mock_perform_query_request_with_no_tools_selected()\n        ]\n        concatenated_text = \"\"\n        tools, _ = tool_definitions_and_executables\n        async for message in stream_request(mock_request, \"test_bot\", tools=tools):\n            concatenated_text += message.text\n        assert concatenated_text == \"there were no tool calls!\"\n        # we should not make a second request if no tools are selected\n        assert mock_perform_query_request_with_tools.call_count == 1\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools_with_bot_returning_regular_response(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n    ) -> None:\n        \"\"\"Test case where the model does not select any tools to call.\"\"\"\n        mock_perform_query_request_with_tools.side_effect = [\n            self.mock_perform_query_request_with_bot_returning_regular_response()\n        ]\n        concatenated_text = \"\"\n        tools, _ = tool_definitions_and_executables\n        async for message in stream_request(mock_request, \"test_bot\", tools=tools):\n            concatenated_text += message.text\n        assert concatenated_text == \"here is the final response\"\n        # we should not make a second request if no tools are selected\n        assert mock_perform_query_request_with_tools.call_count == 1\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools_and_tool_executables(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n        mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n    ) -> None:\n        mock_perform_query_request_with_tools.side_effect = [\n            self.mock_perform_query_request_for_tools(),\n            mock_text_only_query_response,\n        ]\n        concatenated_text = \"\"\n        tools, tool_executables = tool_definitions_and_executables\n        async for message in stream_request(\n            mock_request, \"test_bot\", tools=tools, tool_executables=tool_executables\n        ):\n            concatenated_text += message.text\n        assert concatenated_text == \"Hello, world!\"\n\n        expected_tool_calls = [\n            ToolCallDefinition(\n                id=\"call_123\",\n                type=\"function\",\n                function=FunctionCallDefinition(\n                    name=\"get_current_weather\",\n                    arguments='{\"location\":\"San Francisco, CA\"}',\n                ),\n            ),\n            ToolCallDefinition(\n                id=\"call_456\",\n                type=\"function\",\n                function=FunctionCallDefinition(\n                    name=\"get_current_mayor\", arguments='{\"location\":\"Tokyo, JP\"}'\n                ),\n            ),\n        ]\n        expected_tool_results = [\n            ToolResultDefinition(\n                role=\"tool\",\n                name=\"get_current_weather\",\n                tool_call_id=\"call_123\",\n                content=json.dumps(\n                    tool_executables[0]('{\"location\":\"San Francisco, CA\"}')\n                ),\n            ),\n            ToolResultDefinition(\n                role=\"tool\",\n                name=\"get_current_mayor\",\n                tool_call_id=\"call_456\",\n                content=json.dumps(tool_executables[1]('{\"location\":\"Tokyo, JP\"}')),\n            ),\n        ]\n        # check that the tool calls and results are passed to the second perform_query_request\n        assert {\n            \"tool_calls\": expected_tool_calls,\n            \"tool_results\": expected_tool_results,\n        }.items() <= mock_perform_query_request_with_tools.call_args_list[\n            1\n        ].kwargs.items()\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools_and_tool_executables_missing_first_delta_for_index(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n        mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n    ) -> None:\n        mock_perform_query_request_with_tools.side_effect = [\n            self.mock_perform_query_request_for_tools_missing_first_delta_for_index(),\n            mock_text_only_query_response,\n        ]\n        concatenated_text = \"\"\n        tools, tool_executables = tool_definitions_and_executables\n        async for message in stream_request(\n            mock_request, \"test_bot\", tools=tools, tool_executables=tool_executables\n        ):\n            concatenated_text += message.text\n        assert concatenated_text == \"Hello, world!\"\n\n        # The first delta for index 0 is missing, so we should only have one tool call (index 1).\n        expected_tool_calls = [\n            ToolCallDefinition(\n                id=\"call_456\",\n                type=\"function\",\n                function=FunctionCallDefinition(\n                    name=\"get_current_mayor\", arguments='{\"location\":\"Tokyo, JP\"}'\n                ),\n            )\n        ]\n        expected_tool_results = [\n            ToolResultDefinition(\n                role=\"tool\",\n                name=\"get_current_mayor\",\n                tool_call_id=\"call_456\",\n                content=json.dumps(tool_executables[1]('{\"location\":\"Tokyo, JP\"}')),\n            )\n        ]\n        # check that the tool calls and results are passed to the second perform_query_request\n        assert {\n            \"tool_calls\": expected_tool_calls,\n            \"tool_results\": expected_tool_results,\n        }.items() <= mock_perform_query_request_with_tools.call_args_list[\n            1\n        ].kwargs.items()\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools_and_tool_executables_when_no_tools_selected(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n    ) -> None:\n        \"\"\"Test case where the model does not select any tools to call.\"\"\"\n        mock_perform_query_request_with_tools.side_effect = [\n            self.mock_perform_query_request_with_no_tools_selected()\n        ]\n        concatenated_text = \"\"\n        tools, tool_executables = tool_definitions_and_executables\n        async for message in stream_request(\n            mock_request, \"test_bot\", tools=tools, tool_executables=tool_executables\n        ):\n            concatenated_text += message.text\n        assert concatenated_text == \"there were no tool calls!\"\n        # we should not make a second request if no tools are selected\n        assert mock_perform_query_request_with_tools.call_count == 1\n\n    @patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n    async def test_stream_request_with_tools_index_preserved(\n        self,\n        mock_perform_query_request_with_tools: Mock,\n        mock_request: QueryRequest,\n        tool_definitions_and_executables: tuple[list[ToolDefinition], list[Callable]],\n    ) -> None:\n        \"\"\"Test that index is preserved when yielding tool call responses.\"\"\"\n\n        async def mock_response_with_index() -> AsyncGenerator[BotMessage, None]:\n            mock_delta = {\n                \"tool_calls\": [\n                    {\n                        \"index\": 0,\n                        \"id\": \"call_123\",\n                        \"type\": \"function\",\n                        \"function\": {\"name\": \"get_current_weather\", \"arguments\": \"\"},\n                    }\n                ]\n            }\n            mock_response = self._create_mock_openai_response(mock_delta)\n            message_with_index = BotMessage(text=\"\", data=mock_response, index=1)\n            yield message_with_index\n            mock_response[\"choices\"][0][\"finish_reason\"] = \"tool_calls\"\n            yield BotMessage(text=\"\", data=mock_response, index=1)\n\n        mock_perform_query_request_with_tools.side_effect = [mock_response_with_index()]\n        tools, _ = tool_definitions_and_executables\n\n        messages_with_tool_calls = []\n        async for message in stream_request(mock_request, \"test_bot\", tools=tools):\n            if message.tool_calls:\n                messages_with_tool_calls.append(message)\n\n        assert len(messages_with_tool_calls) > 0\n        # The index should be preserved from the incoming message\n        # If the incoming message has index=1, the tool call message should also have index=1\n        assert (\n            messages_with_tool_calls[0].index == 1\n        ), f\"Expected index=1, got {messages_with_tool_calls[0].index}\"\n\n\n@pytest.mark.asyncio\nclass Test_BotContext:\n\n    @pytest.fixture\n    def mock_bot_context(self) -> _BotContext:\n        return _BotContext(\n            endpoint=\"test_endpoint\",\n            session=AsyncMock(),\n            api_key=\"test_api_key\",\n            on_error=Mock(),\n        )\n\n    def create_sse_mock(\n        self, events: list[ServerSentEvent]\n    ) -> Callable[..., AbstractAsyncContextManager[AsyncMock]]:\n        async def mock_sse_connection(\n            *args: Any, **kwargs: Any  # noqa: ANN401\n        ) -> AsyncIterator[AsyncMock]:\n            mock_source = AsyncMock()\n\n            async def mock_aiter_sse() -> AsyncIterator[ServerSentEvent]:\n                for event in events:\n                    yield event\n\n            mock_source.aiter_sse = mock_aiter_sse\n            yield mock_source\n\n        return asynccontextmanager(mock_sse_connection)\n\n    def test_headers_include_accept_header_by_default(self) -> None:\n        assert _BotContext(endpoint=\"test_endpoint\", session=AsyncMock()).headers == {\n            \"Accept\": \"application/json\"\n        }\n\n    def test_headers_include_api_key_as_auth_header(self) -> None:\n        assert _BotContext(\n            endpoint=\"test_endpoint\", session=AsyncMock(), api_key=\"test_api_key\"\n        ).headers == {\n            \"Accept\": \"application/json\",\n            \"Authorization\": \"Bearer test_api_key\",\n        }\n\n    def test_headers_include_extra_headers(self) -> None:\n        bot_context = _BotContext(\n            endpoint=\"test_endpoint\",\n            session=AsyncMock(),\n            api_key=\"test_api_key\",\n            extra_headers={\"X-Test\": \"test\"},\n        )\n        assert bot_context.headers == {\n            \"Accept\": \"application/json\",\n            \"Authorization\": \"Bearer test_api_key\",\n            \"X-Test\": \"test\",\n        }\n\n    def test_headers_extra_headers_override_default_headers(self) -> None:\n        bot_context = _BotContext(\n            endpoint=\"test_endpoint\",\n            session=AsyncMock(),\n            api_key=\"test_api_key\",\n            extra_headers={\"Accept\": \"application/xml\"},\n        )\n        assert bot_context.headers == {\n            \"Accept\": \"application/xml\",\n            \"Authorization\": \"Bearer test_api_key\",\n        }\n\n    async def test_perform_query_request_text_events(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        events = [\n            ServerSentEvent(event=\"text\", data='{\"text\": \"some\"}'),\n            ServerSentEvent(event=\"text\", data='{\"text\": \" response.\"}'),\n            ServerSentEvent(event=\"done\", data=\"{}\"),\n            ServerSentEvent(\n                event=\"text\", data='{\"text\": \"blahblah\"}'\n            ),  # after done; ignored\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            concatenated_text = \"\"\n            async for message in mock_bot_context.perform_query_request(\n                request=mock_request, tools=None, tool_calls=None, tool_results=None\n            ):\n                concatenated_text += message.text\n            assert concatenated_text == \"some response.\"\n\n    async def test_perform_query_request_non_text_events(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        # other events\n        events = [\n            ServerSentEvent(\n                event=\"meta\",\n                data=(\n                    '{\"suggested_replies\": true, '\n                    '\"content_type\": \"text/markdown\", '\n                    '\"linkify\": true}'\n                ),\n            ),\n            ServerSentEvent(event=\"text\", data='{\"text\": \"some\"}'),\n            ServerSentEvent(\n                event=\"meta\", data='{\"suggested_replies\": true}'\n            ),  # non-first meta event ignored\n            ServerSentEvent(event=\"replace_response\", data='{\"text\": \" response.\"}'),\n            ServerSentEvent(\n                event=\"suggested_reply\", data='{\"text\": \"what do you mean?\"}'\n            ),\n            ServerSentEvent(event=\"json\", data='{\"fruit\": \"apple\"}'),\n            ServerSentEvent(event=\"ping\", data=\"{}\"),\n            ServerSentEvent(event=\"done\", data=\"{}\"),\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            messages = []\n            async for message in mock_bot_context.perform_query_request(\n                request=mock_request, tools=None, tool_calls=None, tool_results=None\n            ):\n                messages.append(message)\n\n            assert messages == [\n                MetaMessage(\n                    text=\"\",\n                    raw_response={\n                        \"suggested_replies\": True,\n                        \"content_type\": \"text/markdown\",\n                        \"linkify\": True,\n                    },\n                    full_prompt=repr(mock_request),\n                    linkify=True,\n                    suggested_replies=True,\n                    content_type=\"text/markdown\",\n                ),\n                BotMessage(\n                    text=\"some\",\n                    raw_response={\"type\": \"text\", \"text\": '{\"text\": \"some\"}'},\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                ),\n                BotMessage(\n                    text=\" response.\",\n                    raw_response={\n                        \"type\": \"replace_response\",\n                        \"text\": '{\"text\": \" response.\"}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=True,\n                ),\n                BotMessage(\n                    text=\"what do you mean?\",\n                    raw_response={\n                        \"type\": \"suggested_reply\",\n                        \"text\": '{\"text\": \"what do you mean?\"}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_suggested_reply=True,\n                ),\n                BotMessage(\n                    text=\"\", full_prompt=repr(mock_request), data={\"fruit\": \"apple\"}\n                ),\n            ]\n\n    async def test_perform_query_request_no_done_event_still_succeeds(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        events = [\n            ServerSentEvent(event=\"text\", data='{\"text\": \"some\"}'),\n            ServerSentEvent(event=\"text\", data='{\"text\": \" response.\"}'),\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            concatenated_text = \"\"\n            async for message in mock_bot_context.perform_query_request(\n                request=mock_request, tools=None, tool_calls=None, tool_results=None\n            ):\n                concatenated_text += message.text\n            assert concatenated_text == \"some response.\"\n\n    async def test_perform_query_request_error_with_allow_retry_false(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        events = [\n            ServerSentEvent(event=\"text\", data='{\"text\": \"some\"}'),\n            ServerSentEvent(event=\"error\", data='{\"allow_retry\": false}'),\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            with pytest.raises(BotErrorNoRetry):\n                async for _ in mock_bot_context.perform_query_request(\n                    request=mock_request, tools=None, tool_calls=None, tool_results=None\n                ):\n                    pass\n\n    async def test_perform_query_request_error_with_allow_retry_true(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        events = [\n            ServerSentEvent(event=\"text\", data='{\"text\": \"some\"}'),\n            ServerSentEvent(event=\"error\", data='{\"allow_retry\": true}'),\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            with pytest.raises(BotError):\n                async for _ in mock_bot_context.perform_query_request(\n                    request=mock_request, tools=None, tool_calls=None, tool_results=None\n                ):\n                    pass\n\n    async def test_perform_query_request_text_events_with_index(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        events = [\n            ServerSentEvent(event=\"text\", data='{\"text\": \"hello\", \"index\": 0}'),\n            ServerSentEvent(event=\"text\", data='{\"text\": \" world\", \"index\": 0}'),\n            ServerSentEvent(event=\"text\", data='{\"text\": \"hi.\", \"index\": 1}'),\n            # Bad index value should be ignored\n            ServerSentEvent(\n                event=\"text\", data='{\"text\": \"text with bad index\", \"index\": \"banana\"}'\n            ),\n            ServerSentEvent(event=\"done\", data=\"{}\"),\n            ServerSentEvent(\n                event=\"text\", data='{\"text\": \"blahblah\", \"index\": 2}'\n            ),  # after done; ignored\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            messages = []\n            async for message in mock_bot_context.perform_query_request(\n                request=mock_request, tools=None, tool_calls=None, tool_results=None\n            ):\n                messages.append(message)\n\n            assert messages == [\n                BotMessage(\n                    text=\"hello\",\n                    raw_response={\n                        \"type\": \"text\",\n                        \"text\": '{\"text\": \"hello\", \"index\": 0}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                    index=0,\n                ),\n                BotMessage(\n                    text=\" world\",\n                    raw_response={\n                        \"type\": \"text\",\n                        \"text\": '{\"text\": \" world\", \"index\": 0}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                    index=0,\n                ),\n                BotMessage(\n                    text=\"hi.\",\n                    raw_response={\n                        \"type\": \"text\",\n                        \"text\": '{\"text\": \"hi.\", \"index\": 1}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                    index=1,\n                ),\n                BotMessage(\n                    text=\"text with bad index\",\n                    raw_response={\n                        \"type\": \"text\",\n                        \"text\": '{\"text\": \"text with bad index\", \"index\": \"banana\"}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                    index=None,\n                ),\n            ]\n\n    async def test_perform_query_request_non_text_events_with_index(\n        self, mock_bot_context: _BotContext, mock_request: QueryRequest\n    ) -> None:\n        # other events\n        events = [\n            ServerSentEvent(event=\"text\", data='{\"text\": \"first\", \"index\": 0}'),\n            ServerSentEvent(\n                event=\"text\", data='{\"text\": \"second message\", \"index\": 1}'\n            ),\n            ServerSentEvent(\n                event=\"replace_response\", data='{\"text\": \" message.\", \"index\": 0}'\n            ),\n            ServerSentEvent(\n                event=\"suggested_reply\",\n                data='{\"text\": \"what do you mean?\", \"index\": 1}',\n            ),\n            ServerSentEvent(event=\"json\", data='{\"fruit\": \"apple\", \"index\": 1}'),\n            ServerSentEvent(event=\"ping\", data='{\"index\": 1}'),\n            ServerSentEvent(event=\"done\", data='{\"index\": 1}'),\n        ]\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            messages = []\n            async for message in mock_bot_context.perform_query_request(\n                request=mock_request, tools=None, tool_calls=None, tool_results=None\n            ):\n                messages.append(message)\n\n            assert messages == [\n                BotMessage(\n                    text=\"first\",\n                    raw_response={\n                        \"type\": \"text\",\n                        \"text\": '{\"text\": \"first\", \"index\": 0}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                    index=0,\n                ),\n                BotMessage(\n                    text=\"second message\",\n                    raw_response={\n                        \"type\": \"text\",\n                        \"text\": '{\"text\": \"second message\", \"index\": 1}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=False,\n                    index=1,\n                ),\n                BotMessage(\n                    text=\" message.\",\n                    raw_response={\n                        \"type\": \"replace_response\",\n                        \"text\": '{\"text\": \" message.\", \"index\": 0}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_replace_response=True,\n                    index=0,\n                ),\n                BotMessage(\n                    text=\"what do you mean?\",\n                    raw_response={\n                        \"type\": \"suggested_reply\",\n                        \"text\": '{\"text\": \"what do you mean?\", \"index\": 1}',\n                    },\n                    full_prompt=repr(mock_request),\n                    is_suggested_reply=True,\n                    index=1,\n                ),\n                BotMessage(\n                    text=\"\",\n                    full_prompt=repr(mock_request),\n                    data={\"fruit\": \"apple\", \"index\": 1},\n                    index=1,\n                ),\n            ]\n\n    @pytest.mark.parametrize(\n        \"events\",\n        [\n            [\n                ServerSentEvent(\n                    event=\"meta\", data='{\"suggested_replies\": \"true\"}'\n                ),  # not bool\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n            [\n                ServerSentEvent(event=\"meta\", data='{\"linkify\": \"banana\"}'),  # not bool\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n            [\n                ServerSentEvent(event=\"meta\", data='{\"content_type\": 123}'),  # not str\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n            [ServerSentEvent(event=\"done\", data=\"{}\")],  # no text in response\n            [\n                ServerSentEvent(event=\"bad\", data='{\"text\": \"some\"}'),  # unknown event\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n            [\n                ServerSentEvent(event=\"text\", data='{\"text\": banana}'),  # improper json\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n            [\n                ServerSentEvent(event=\"text\", data='{\"text\": 123}'),  # not str\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n            [\n                ServerSentEvent(event=\"meta\", data=\"123\"),  # not dict\n                ServerSentEvent(event=\"done\", data=\"{}\"),\n            ],\n        ],\n    )\n    async def test_perform_query_request_with_error(\n        self,\n        mock_bot_context: _BotContext,\n        mock_request: QueryRequest,\n        events: list[ServerSentEvent],\n    ) -> None:\n        with patch(\"httpx_sse.aconnect_sse\") as mock_connect_sse:\n            mock_connect_sse.side_effect = self.create_sse_mock(events)\n            try:\n                async for _ in mock_bot_context.perform_query_request(\n                    request=mock_request, tools=None, tool_calls=None, tool_results=None\n                ):\n                    pass\n                cast(Mock, mock_bot_context.on_error).assert_called_once()\n            except Exception:\n                pass\n\n\n@pytest.mark.asyncio\n@patch(\"fastapi_poe.client._BotContext.perform_query_request\")\nasync def test_get_final_response(\n    mock_perform_query_request: Mock,\n    mock_request: QueryRequest,\n    mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n) -> None:\n    mock_perform_query_request.return_value = mock_text_only_query_response\n    final_response = await get_final_response(mock_request, \"test_bot\")\n    assert final_response == \"Hello, world!\"\n\n\n@pytest.mark.asyncio\n@patch(\"fastapi_poe.client._BotContext.perform_query_request\")\nasync def test_get_bot_response(\n    mock_perform_query_request: Mock,\n    mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n) -> None:\n    mock_perform_query_request.return_value = mock_text_only_query_response\n\n    mock_protocol_messages = [\n        ProtocolMessage(role=\"user\", content=\"Hello, world!\", sender=Sender())\n    ]\n\n    concatenated_text = \"\"\n    async for message in get_bot_response(\n        mock_protocol_messages,\n        \"test_bot\",\n        api_key=\"test_api_key\",\n        temperature=0.5,\n        skip_system_prompt=True,\n        logit_bias={},\n        stop_sequences=[\"foo\"],\n    ):\n        concatenated_text += message.text\n    assert concatenated_text == \"Hello, world!\"\n\n\n@patch(\"fastapi_poe.client._BotContext.perform_query_request\")\ndef test_get_bot_response_sync(\n    mock_perform_query_request: Mock,\n    mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n) -> None:\n    mock_perform_query_request.return_value = mock_text_only_query_response\n\n    mock_protocol_messages = [\n        ProtocolMessage(role=\"user\", content=\"Hello, world!\", sender=Sender())\n    ]\n\n    concatenated_text = \"\"\n    for message in get_bot_response_sync(\n        mock_protocol_messages,\n        \"test_bot\",\n        api_key=\"test_api_key\",\n        temperature=0.5,\n        skip_system_prompt=True,\n        logit_bias={},\n        stop_sequences=[\"foo\"],\n    ):\n        concatenated_text += message.text\n    assert concatenated_text == \"Hello, world!\"\n\n\n@patch(\"fastapi_poe.client._BotContext.perform_query_request\")\n@pytest.mark.asyncio\nasync def test_get_bot_response_with_adopt_current_bot_name(\n    mock_perform_query_request: Mock,\n    mock_text_only_query_response: AsyncGenerator[BotMessage, None],\n) -> None:\n    \"\"\"Test that adopt_current_bot_name parameter works with get_bot_response.\"\"\"\n    mock_perform_query_request.return_value = mock_text_only_query_response\n\n    messages = [ProtocolMessage(role=\"user\", content=\"Hello, world!\")]\n\n    concatenated_text = \"\"\n    async for message in get_bot_response(\n        messages, \"test_bot\", api_key=\"test_api_key\", adopt_current_bot_name=True\n    ):\n        concatenated_text += message.text\n\n    assert concatenated_text == \"Hello, world!\"\n    mock_perform_query_request.assert_called_once()\n\n\n@pytest.mark.parametrize(\n    \"test_input, limit, expected\",\n    [\n        (\"hello world\", 5, \"he...\"),\n        (\"test\", 10, \"test\"),\n        (123, 5, \"123\"),\n        ([1, 2, 3], 7, \"[1, ...\"),\n        (None, 6, \"None\"),\n        (\"\", 5, \"\"),\n    ],\n)\ndef test__safe_ellipsis(test_input: object, limit: int, expected: str) -> None:\n    result = _safe_ellipsis(test_input, limit)\n    assert result == expected\n\n\n@patch(\"httpx.post\")\ndef test_sync_bot_settings(mock_httpx_post: Mock) -> None:\n    mock_httpx_post.return_value = Mock(status_code=200, text=\"{}\")\n    sync_bot_settings(\"test_bot\", access_key=\"test_access_key\", settings={\"foo\": \"bar\"})\n    mock_httpx_post.assert_called_once_with(\n        \"https://api.poe.com/bot/update_settings/test_bot/test_access_key/1.2\",\n        json={\"foo\": \"bar\"},\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    mock_httpx_post.reset_mock()\n\n    sync_bot_settings(\"test_bot\", access_key=\"test_access_key\")\n    mock_httpx_post.assert_called_once_with(\n        \"https://api.poe.com/bot/fetch_settings/test_bot/test_access_key/1.2\",\n        # TODO: pass headers?\n        # headers={\"Content-Type\": \"application/json\"},\n    )\n    mock_httpx_post.reset_mock()\n\n    mock_httpx_post.return_value = Mock(status_code=500, text=\"{}\")\n    with pytest.raises(BotError):\n        sync_bot_settings(\"test_bot\", access_key=\"test_access_key\")\n\n    mock_httpx_post.side_effect = httpx.ReadTimeout(\"timeout\")\n    with pytest.raises(BotError):\n        sync_bot_settings(\"test_bot\", access_key=\"test_access_key\")\n\n\ndef _make_mock_async_client(\n    fake_send: Callable[[httpx.Request], Awaitable[httpx.Response]]\n) -> httpx.AsyncClient:\n    \"\"\"\n    Builds an `httpx.AsyncClient` double whose `send` coroutine is supplied\n    by the caller (`fake_send`).\n\n    \"\"\"\n    client = AsyncMock(spec=httpx.AsyncClient)\n\n    client.__aenter__.return_value = client\n    client.__aexit__.return_value = None\n\n    client.build_request = Mock(\n        side_effect=lambda *args, **kwargs: httpx.Request(*args, **kwargs)\n    )\n    client.send = AsyncMock(side_effect=fake_send)\n\n    return client\n\n\n@pytest.mark.asyncio\nasync def test_upload_file_via_url() -> None:\n    expected_json = {\n        \"attachment_url\": \"https://cdn.example.com/fake-id/file.txt\",\n        \"mime_type\": \"text/plain\",\n    }\n\n    async def fake_send(request: httpx.Request) -> httpx.Response:\n        return httpx.Response(\n            status_code=200,\n            content=json.dumps(expected_json).encode(),\n            headers={\"content-type\": \"application/json\"},\n        )\n\n    mock_client = _make_mock_async_client(fake_send)\n    with patch(\"httpx.AsyncClient\", return_value=mock_client):\n        attachment = await upload_file(\n            file_url=\"https://example.com/file.txt\",\n            file_name=\"file.txt\",\n            api_key=\"secret-key\",\n        )\n\n    # Attachment object\n    assert attachment.url == expected_json[\"attachment_url\"]\n    assert attachment.content_type == expected_json[\"mime_type\"]\n    assert attachment.name == \"file.txt\"\n\n    # HTTP request\n    send_mock: AsyncMock = cast(AsyncMock, mock_client.send)  # satisfy pyright\n    req: httpx.Request = send_mock.call_args.args[0]\n    assert req.url.path.endswith(\"/file_upload_3RD_PARTY_POST\")\n    assert req.method == \"POST\"\n    assert req.headers[\"Authorization\"] == \"secret-key\"\n\n\n@pytest.mark.asyncio\nasync def test_upload_file_raw_bytes() -> None:\n    expected_json = {\n        \"attachment_url\": \"https://cdn.example.com/fake-id/hello.txt\",\n        \"mime_type\": \"text/plain\",\n    }\n\n    async def fake_send(request: httpx.Request) -> httpx.Response:\n        return httpx.Response(\n            status_code=200,\n            content=json.dumps(expected_json).encode(),\n            headers={\"content-type\": \"application/json\"},\n        )\n\n    mock_client = _make_mock_async_client(fake_send)\n    with patch(\"httpx.AsyncClient\", return_value=mock_client):\n        attachment = await upload_file(\n            file=b\"hello world\", file_name=\"hello.txt\", api_key=\"secret-key\"\n        )\n\n    # Attachment object\n    assert attachment.url == expected_json[\"attachment_url\"]\n    assert attachment.content_type == expected_json[\"mime_type\"]\n    assert attachment.name == \"hello.txt\"\n\n    # HTTP request\n    send_mock: AsyncMock = cast(AsyncMock, mock_client.send)  # satisfy pyright\n    req: httpx.Request = send_mock.call_args.args[0]\n    assert req.headers[\"Authorization\"] == \"secret-key\"\n    assert req.headers[\"Content-Type\"].startswith(\"multipart/form-data\")\n\n\n@pytest.mark.asyncio\nasync def test_upload_file_error_raises() -> None:\n    async def fake_send(_: httpx.Request) -> httpx.Response:\n        return httpx.Response(status_code=500, content=b\"internal error\")\n\n    with (\n        patch(\"httpx.AsyncClient\", return_value=_make_mock_async_client(fake_send)),\n        pytest.raises(AttachmentUploadError),\n    ):\n        await upload_file(file_url=\"https://example.com/file.txt\", api_key=\"secret-key\")\n\n\n@pytest.mark.asyncio\nasync def test_upload_file_with_extra_headers() -> None:\n    expected_json = {\n        \"attachment_url\": \"https://cdn.example.com/fake-id/file.txt\",\n        \"mime_type\": \"text/plain\",\n    }\n\n    async def fake_send(request: httpx.Request) -> httpx.Response:\n        return httpx.Response(\n            status_code=200,\n            content=json.dumps(expected_json).encode(),\n            headers={\"content-type\": \"application/json\"},\n        )\n\n    mock_client = _make_mock_async_client(fake_send)\n    with patch(\"httpx.AsyncClient\", return_value=mock_client):\n        attachment = await upload_file(\n            file_url=\"https://example.com/file.txt\",\n            file_name=\"file.txt\",\n            api_key=\"secret-key\",\n            extra_headers={\"X-Custom-Header\": \"custom-value\"},\n        )\n\n    # Attachment object\n    assert attachment.url == expected_json[\"attachment_url\"]\n    assert attachment.content_type == expected_json[\"mime_type\"]\n    assert attachment.name == \"file.txt\"\n\n    # HTTP request should include both auth and custom headers\n    send_mock: AsyncMock = cast(AsyncMock, mock_client.send)\n    req: httpx.Request = send_mock.call_args.args[0]\n    assert req.headers[\"Authorization\"] == \"secret-key\"\n    assert req.headers[\"X-Custom-Header\"] == \"custom-value\"\n"
  },
  {
    "path": "tests/test_sync_utils.py",
    "content": "import asyncio\n\nimport pytest\nfrom fastapi_poe.sync_utils import run_sync\n\n\nasync def _add(a: int, b: int) -> int:\n    await asyncio.sleep(0.01)\n    return a + b\n\n\ndef test_run_sync_without_event_loop() -> None:\n    assert run_sync(_add(1, 2)) == 3\n\n\n@pytest.mark.asyncio\nasync def test_run_sync_inside_event_loop() -> None:\n    assert run_sync(_add(4, 5)) == 9\n\n\n@pytest.mark.asyncio\nasync def test_run_sync_rejects_session_inside_loop() -> None:\n    with pytest.raises(ValueError):\n        run_sync(_add(1, 1), session=\"dummy\")\n\n\ndef test_run_sync_propagates_exceptions() -> None:\n    async def _boom() -> None:\n        raise RuntimeError(\"kaboom\")\n\n    with pytest.raises(RuntimeError, match=\"kaboom\"):\n        run_sync(_boom())\n"
  },
  {
    "path": "tests/test_types.py",
    "content": "import pydantic\nimport pytest\nfrom fastapi_poe.types import (\n    CostItem,\n    CustomCallDefinition,\n    CustomToolDefinition,\n    MessageReaction,\n    PartialResponse,\n    ProtocolMessage,\n    QueryRequest,\n    Sender,\n    SettingsResponse,\n    User,\n)\n\n\nclass TestSettingsResponse:\n\n    def test_default_response_version(self) -> None:\n        response = SettingsResponse()\n        assert response.response_version == 2\n\n\ndef test_extra_attrs() -> None:\n    with pytest.raises(pydantic.ValidationError):\n        PartialResponse(text=\"hi\", replaceResponse=True)  # type: ignore\n\n    resp = PartialResponse(text=\"a capybara\", is_replace_response=True)\n    assert resp.is_replace_response is True\n    assert resp.text == \"a capybara\"\n\n\ndef test_cost_item() -> None:\n    with pytest.raises(pydantic.ValidationError):\n        CostItem(amount_usd_milli_cents=\"1\")  # type: ignore\n\n    item = CostItem(amount_usd_milli_cents=25)\n    assert item.amount_usd_milli_cents == 25\n    assert item.description is None\n\n    item = CostItem(amount_usd_milli_cents=25.5, description=\"Test\")  # type: ignore\n    assert item.amount_usd_milli_cents == 26\n    assert item.description == \"Test\"\n\n\nclass TestSender:\n\n    def test_sender_basic(self) -> None:\n        sender = Sender()\n        assert sender.id is None\n        assert sender.name is None\n\n    def test_sender_with_id(self) -> None:\n        sender = Sender(id=\"user123\")\n        assert sender.id == \"user123\"\n        assert sender.name is None\n\n    def test_sender_with_name(self) -> None:\n        sender = Sender(name=\"TestBot\")\n        assert sender.id is None\n        assert sender.name == \"TestBot\"\n\n    def test_sender_with_all_fields(self) -> None:\n        sender = Sender(id=\"bot456\", name=\"MyBot\")\n        assert sender.id == \"bot456\"\n        assert sender.name == \"MyBot\"\n\n\nclass TestUser:\n\n    def test_user_basic(self) -> None:\n        user = User(id=\"user123\")\n        assert user.id == \"user123\"\n        assert user.name is None\n\n    def test_user_with_name(self) -> None:\n        user = User(id=\"user456\", name=\"Alice\")\n        assert user.id == \"user456\"\n        assert user.name == \"Alice\"\n\n    def test_user_requires_id(self) -> None:\n        with pytest.raises(pydantic.ValidationError):\n            User()  # type: ignore\n\n\nclass TestMessageReaction:\n\n    def test_reaction_basic(self) -> None:\n        reaction = MessageReaction(user_id=\"user123\", reaction=\"like\")\n        assert reaction.user_id == \"user123\"\n        assert reaction.reaction == \"like\"\n\n    def test_reaction_requires_user_id(self) -> None:\n        with pytest.raises(pydantic.ValidationError):\n            MessageReaction(reaction=\"like\")  # type: ignore\n\n    def test_reaction_requires_reaction(self) -> None:\n        with pytest.raises(pydantic.ValidationError):\n            MessageReaction(user_id=\"user123\")  # type: ignore\n\n\nclass TestProtocolMessage:\n\n    def test_protocol_message_basic(self) -> None:\n        msg = ProtocolMessage(role=\"user\", sender=Sender(), content=\"Hello, world!\")\n        assert msg.role == \"user\"\n        assert isinstance(msg.sender, Sender)\n        assert msg.content == \"Hello, world!\"\n        assert msg.reactions == []\n        assert msg.referenced_message is None\n\n    def test_protocol_message_with_reactions(self) -> None:\n        msg = ProtocolMessage(\n            role=\"user\",\n            sender=Sender(),\n            content=\"Hello!\",\n            reactions=[\n                MessageReaction(user_id=\"user1\", reaction=\"like\"),\n                MessageReaction(user_id=\"user2\", reaction=\"dislike\"),\n            ],\n        )\n        assert len(msg.reactions) == 2\n        assert msg.reactions[0].user_id == \"user1\"\n        assert msg.reactions[0].reaction == \"like\"\n        assert msg.reactions[1].user_id == \"user2\"\n        assert msg.reactions[1].reaction == \"dislike\"\n\n    def test_protocol_message_with_referenced_message(self) -> None:\n        referenced_msg = ProtocolMessage(\n            role=\"user\",\n            sender=Sender(),\n            content=\"Original message\",\n            message_id=\"msg123\",\n        )\n        reply_msg = ProtocolMessage(\n            role=\"bot\",\n            sender=Sender(),\n            content=\"Reply to original\",\n            referenced_message=referenced_msg,\n        )\n        assert reply_msg.referenced_message is not None\n        assert reply_msg.referenced_message.content == \"Original message\"\n        assert reply_msg.referenced_message.message_id == \"msg123\"\n\n    def test_protocol_message_optional_sender(self) -> None:\n        # Sender is now optional\n        msg = ProtocolMessage(role=\"user\", content=\"Hello\")\n        assert msg.role == \"user\"\n        assert msg.sender is None\n        assert msg.content == \"Hello\"\n\n    def test_protocol_message_nested_referenced_message(self) -> None:\n        # Test deeply nested referenced messages\n        msg1 = ProtocolMessage(\n            role=\"user\", sender=Sender(), content=\"First message\", message_id=\"msg1\"\n        )\n        msg2 = ProtocolMessage(\n            role=\"bot\",\n            sender=Sender(),\n            content=\"Second message\",\n            message_id=\"msg2\",\n            referenced_message=msg1,\n        )\n        msg3 = ProtocolMessage(\n            role=\"user\",\n            sender=Sender(),\n            content=\"Third message\",\n            message_id=\"msg3\",\n            referenced_message=msg2,\n        )\n        assert msg3.referenced_message is not None\n        assert msg3.referenced_message.message_id == \"msg2\"\n        assert msg3.referenced_message.referenced_message is not None\n        assert msg3.referenced_message.referenced_message.message_id == \"msg1\"\n\n    def test_protocol_message_with_sender_object(self) -> None:\n        sender = Sender(id=\"user123\", name=\"TestUser\")\n        msg = ProtocolMessage(role=\"user\", sender=sender, content=\"Hello, world!\")\n        assert msg.role == \"user\"\n        assert msg.sender == sender\n        assert msg.sender is not None\n        assert msg.sender.id == \"user123\"\n        assert msg.sender.name == \"TestUser\"\n        assert msg.content == \"Hello, world!\"\n\n\nclass TestQueryRequest:\n\n    def test_query_request_with_users(self) -> None:\n        query_request = QueryRequest(\n            version=\"1.0\",\n            type=\"query\",\n            query=[ProtocolMessage(role=\"user\", sender=Sender(), content=\"Hello\")],\n            user_id=\"user123\",\n            conversation_id=\"conv456\",\n            message_id=\"msg789\",\n            users=[User(id=\"user1\", name=\"Alice\"), User(id=\"user2\", name=\"Bob\")],\n        )\n        assert len(query_request.users) == 2\n        assert query_request.users[0].id == \"user1\"\n        assert query_request.users[0].name == \"Alice\"\n        assert query_request.users[1].id == \"user2\"\n        assert query_request.users[1].name == \"Bob\"\n\n    def test_query_request_empty_users(self) -> None:\n        query_request = QueryRequest(\n            version=\"1.0\",\n            type=\"query\",\n            query=[ProtocolMessage(role=\"user\", sender=Sender(), content=\"Hello\")],\n            user_id=\"user123\",\n            conversation_id=\"conv456\",\n            message_id=\"msg789\",\n        )\n        assert query_request.users == []\n\n    def test_query_request_with_reactions_in_messages(self) -> None:\n        query_request = QueryRequest(\n            version=\"1.0\",\n            type=\"query\",\n            query=[\n                ProtocolMessage(\n                    role=\"user\",\n                    sender=Sender(id=\"user1\"),\n                    content=\"Hello\",\n                    reactions=[MessageReaction(user_id=\"user2\", reaction=\"like\")],\n                )\n            ],\n            user_id=\"user123\",\n            conversation_id=\"conv456\",\n            message_id=\"msg789\",\n        )\n        assert len(query_request.query[0].reactions) == 1\n        assert query_request.query[0].reactions[0].reaction == \"like\"\n\n\nclass TestCustomToolDefinition:\n\n    def test_basic_instantiation(self) -> None:\n        \"\"\"Test creating CustomToolDefinition with alias 'format'\"\"\"\n        tool = CustomToolDefinition(\n            name=\"my_tool\",\n            description=\"A custom tool\",\n            format={\"type\": \"object\", \"properties\": {}},\n        )\n        assert tool.name == \"my_tool\"\n        assert tool.description == \"A custom tool\"\n        assert tool.format_ == {\"type\": \"object\", \"properties\": {}}\n\n    def test_field_name_works_with_populate_by_name(self) -> None:\n        \"\"\"Test that 'format_' field name also works due to populate_by_name=True\"\"\"\n        tool = CustomToolDefinition(\n            name=\"my_tool\",\n            description=\"A custom tool\",\n            format_={\"type\": \"string\"},  # type: ignore\n        )\n        assert tool.format_ == {\"type\": \"string\"}\n\n    def test_requires_name_field(self) -> None:\n        \"\"\"Test that name field is required\"\"\"\n        with pytest.raises(pydantic.ValidationError):\n            CustomToolDefinition()  # type: ignore\n\n    def test_optional_fields(self) -> None:\n        \"\"\"Test that description and format are optional\"\"\"\n        tool = CustomToolDefinition(name=\"my_tool\")\n        assert tool.name == \"my_tool\"\n        assert tool.description is None\n        assert tool.format_ is None\n\n    def test_with_only_name_and_description(self) -> None:\n        \"\"\"Test with only name and description\"\"\"\n        tool = CustomToolDefinition(name=\"tool\", description=\"desc\")\n        assert tool.name == \"tool\"\n        assert tool.description == \"desc\"\n        assert tool.format_ is None\n\n    def test_with_only_name_and_format(self) -> None:\n        \"\"\"Test with only name and format\"\"\"\n        tool = CustomToolDefinition(name=\"tool\", format={\"type\": \"string\"})\n        assert tool.name == \"tool\"\n        assert tool.description is None\n        assert tool.format_ == {\"type\": \"string\"}\n\n    def test_serialization_uses_alias(self) -> None:\n        \"\"\"Test that serialization uses 'format' not 'format_'\"\"\"\n        tool = CustomToolDefinition(\n            name=\"my_tool\", description=\"desc\", format={\"key\": \"value\"}\n        )\n        data = tool.model_dump(by_alias=True)\n        assert \"format\" in data\n        assert \"format_\" not in data\n        assert data[\"format\"] == {\"key\": \"value\"}\n\n    def test_serialization_without_alias(self) -> None:\n        \"\"\"Test that serialization without by_alias uses 'format_'\"\"\"\n        tool = CustomToolDefinition(\n            name=\"my_tool\", description=\"desc\", format={\"key\": \"value\"}\n        )\n        data = tool.model_dump(by_alias=False)\n        assert \"format_\" in data\n        assert \"format\" not in data\n        assert data[\"format_\"] == {\"key\": \"value\"}\n\n    def test_json_serialization(self) -> None:\n        \"\"\"Test JSON serialization with alias\"\"\"\n        tool = CustomToolDefinition(\n            name=\"tool\", description=\"desc\", format={\"nested\": \"data\"}\n        )\n        json_str = tool.model_dump_json(by_alias=True)\n        assert '\"format\"' in json_str\n        assert '\"format_\"' not in json_str\n\n    def test_deserialization_from_json(self) -> None:\n        \"\"\"Test deserializing from JSON with alias\"\"\"\n        json_data = {\n            \"name\": \"tool1\",\n            \"description\": \"A tool\",\n            \"format\": {\"type\": \"array\"},\n        }\n        tool = CustomToolDefinition(**json_data)\n        assert tool.name == \"tool1\"\n        assert tool.format_ == {\"type\": \"array\"}\n\n    def test_invalid_type_for_format(self) -> None:\n        \"\"\"Test that format must be a dict\"\"\"\n        with pytest.raises(pydantic.ValidationError):\n            CustomToolDefinition(name=\"tool\", description=\"desc\", format=\"not a dict\")  # type: ignore\n\n\nclass TestCustomCallDefinition:\n\n    def test_basic_instantiation(self) -> None:\n        \"\"\"Test creating CustomCallDefinition with alias 'input'\"\"\"\n        call = CustomCallDefinition(name=\"my_tool\", input='{\"arg\": \"value\"}')\n        assert call.name == \"my_tool\"\n        assert call.input_ == '{\"arg\": \"value\"}'\n\n    def test_field_name_works_with_populate_by_name(self) -> None:\n        \"\"\"Test that 'input_' field name also works due to populate_by_name=True\"\"\"\n        call = CustomCallDefinition(name=\"my_tool\", input_='{\"data\": 123}')  # type: ignore\n        assert call.input_ == '{\"data\": 123}'\n\n    def test_requires_all_fields(self) -> None:\n        \"\"\"Test that all required fields are validated\"\"\"\n        with pytest.raises(pydantic.ValidationError):\n            CustomCallDefinition(name=\"my_tool\")  # type: ignore\n\n        with pytest.raises(pydantic.ValidationError):\n            CustomCallDefinition(input=\"data\")  # type: ignore\n\n    def test_serialization_uses_alias(self) -> None:\n        \"\"\"Test that serialization uses 'input' not 'input_'\"\"\"\n        call = CustomCallDefinition(name=\"tool1\", input=\"test_input\")\n        data = call.model_dump(by_alias=True)\n        assert \"input\" in data\n        assert \"input_\" not in data\n        assert data[\"input\"] == \"test_input\"\n\n    def test_serialization_without_alias(self) -> None:\n        \"\"\"Test that serialization without by_alias uses 'input_'\"\"\"\n        call = CustomCallDefinition(name=\"tool1\", input=\"test_input\")\n        data = call.model_dump(by_alias=False)\n        assert \"input_\" in data\n        assert \"input\" not in data\n        assert data[\"input_\"] == \"test_input\"\n\n    def test_json_serialization(self) -> None:\n        \"\"\"Test JSON serialization with alias\"\"\"\n        call = CustomCallDefinition(name=\"calculator\", input='{\"operation\": \"add\"}')\n        json_str = call.model_dump_json(by_alias=True)\n        assert '\"input\"' in json_str\n        assert '\"input_\"' not in json_str\n\n    def test_deserialization_from_json(self) -> None:\n        \"\"\"Test deserializing from JSON with alias\"\"\"\n        json_data = {\n            \"name\": \"calculator\",\n            \"input\": '{\"operation\": \"add\", \"a\": 1, \"b\": 2}',\n        }\n        call = CustomCallDefinition(**json_data)\n        assert call.name == \"calculator\"\n        assert call.input_ == '{\"operation\": \"add\", \"a\": 1, \"b\": 2}'\n"
  }
]