Full Code of poe-platform/fastapi_poe for AI

main 41ffd02e16f2 cached
26 files
255.9 KB
56.8k tokens
256 symbols
1 requests
Download .txt
Showing preview only (267K chars total). Download the full file or copy to clipboard to get everything.
Repository: poe-platform/fastapi_poe
Branch: main
Commit: 41ffd02e16f2
Files: 26
Total size: 255.9 KB

Directory structure:
gitextract_eeb9sykf/

├── .flake8
├── .github/
│   ├── CODEOWNERS
│   ├── pull_request_template.md
│   └── workflows/
│       ├── lint.yml
│       └── publish.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .prettierignore
├── .prettierrc.yaml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── docs/
│   ├── api_reference.md
│   └── generate_api_reference.py
├── pyproject.toml
├── src/
│   └── fastapi_poe/
│       ├── __init__.py
│       ├── base.py
│       ├── client.py
│       ├── py.typed
│       ├── sync_utils.py
│       ├── templates.py
│       └── types.py
└── tests/
    ├── test_base.py
    ├── test_client.py
    ├── test_sync_utils.py
    └── test_types.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .flake8
================================================
[flake8]
max-line-length = 100
extend-immutable-calls = Depends, fastapi.Depends, fastapi.params.Depends


================================================
FILE: .github/CODEOWNERS
================================================
# This file is used to automatically assign reviewers to PRs
# For more information see: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners

* @poe-platform/fastapi_poe_reviewers


================================================
FILE: .github/pull_request_template.md
================================================
## Description

Brief description of changes

## Changes Made

- Detailed change 1
- Detailed change 2

## Testing Done

- Test scenario 1
- Test scenario 2

## Version

- Updated to version 0.0.49

## Breaking Changes

- List any breaking changes

## Checklist

- [ ] Tests added
- [ ] Documentation updated
- [ ] Version bumped
- [ ] Changes tested locally


================================================
FILE: .github/workflows/lint.yml
================================================
name: Lint

on: [push, pull_request]

jobs:
  precommit:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up latest Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"

      - name: Run pre-commit hooks
        uses: pre-commit/action@v3.0.0

  pyright:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up latest Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          python -m pip install .[dev]

      - uses: jakebailey/pyright-action@v1

  tests:
    name: unit tests
    timeout-minutes: 10
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: ["ubuntu-latest", "macos-latest", "windows-latest"]
        python-version: ["3.9", "3.10", "3.11", "3.12"]
      fail-fast: false
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
          allow-prereleases: true
          cache: pip
          cache-dependency-path: pyproject.toml
      - run: pip install -e ".[dev]"
      - run: pytest tests/


================================================
FILE: .github/workflows/publish.yml
================================================
# Based on
# https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/

name: Publish Python distribution to PyPI

on: push

jobs:
  build:
    name: Build distribution
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.x"
      - name: Install pypa/build
        run: python3 -m pip install --user build
      - name: Build a binary wheel and a source tarball
        run: python3 -m build
      - name: Store the distribution packages
        uses: actions/upload-artifact@v4
        with:
          name: python-package-distributions
          path: dist/

  publish-to-pypi:
    name: >-
      Publish Python distribution to PyPI
    if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
    needs:
      - build
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/fastapi-poe
    permissions:
      id-token: write # IMPORTANT: mandatory for trusted publishing

    steps:
      - name: Download all the dists
        uses: actions/download-artifact@v4
        with:
          name: python-package-distributions
          path: dist/
      - name: Publish distribution to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1


================================================
FILE: .gitignore
================================================
__pycache__/
.DS_Store
.coverage
venv/


================================================
FILE: .pre-commit-config.yaml
================================================
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.2.2
    hooks:
      - id: ruff
        args: [--fix]

  - repo: https://github.com/psf/black
    rev: 24.2.0
    hooks:
      - id: black
        language_version: python3.11

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.4.0
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier


================================================
FILE: .prettierignore
================================================
docs/*.md


================================================
FILE: .prettierrc.yaml
================================================
proseWrap: always
printWidth: 88
endOfLine: auto


================================================
FILE: CONTRIBUTING.md
================================================
# General

- All changes should be made through pull requests
- Pull requests should only be merged once all checks pass
- The repo uses Black for formatting Python code, Prettier for formatting Markdown,
  Pyright for type-checking Python, and a few other tools
- To generate reference documentation, follow the instructions in
  docs/generate_api_reference.py
- To run the CI checks locally:
  - `pip install pre-commit`
  - `pre-commit run --all` (or `pre-commit install` to install the pre-commit hook)

# Releases

To release a new version of `fastapi_poe`, do the following:

- Make a PR updating the version number in `pyproject.toml` (example:
  https://github.com/poe-platform/fastapi_poe/pull/2)
- Merge it once CI passes
- Go to https://github.com/poe-platform/fastapi_poe/releases/new and make a new release
  (note this link works only if you have commit access to this repository)
- The tag should be of the form "0.0.X".
- Fill in the release notes with some description of what changed since the last
  release.
- [GitHub Actions](https://github.com/poe-platform/fastapi_poe/actions) will generate
  the release artefacts and upload them to PyPI
- You can check [PyPI](https://pypi.org/project/fastapi-poe/) to verify that the release
  went through.


================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.


================================================
FILE: README.md
================================================
# fastapi_poe

An implementation of the
[Poe protocol](https://creator.poe.com/docs/poe-protocol-specification) using FastAPI.

### Installation

Install the package from PyPI:

```bash
pip install fastapi-poe
```

### Write your own bot

This package can also be used as a base to write your own bot. You can inherit from
`PoeBot` to make a bot:

```python
import fastapi_poe as fp

class EchoBot(fp.PoeBot):
    async def get_response(self, request: fp.QueryRequest):
        last_message = request.query[-1].content
        yield fp.PartialResponse(text=last_message)

if __name__ == "__main__":
    fp.run(EchoBot(), allow_without_key=True)
```

Now, run your bot using `python <filename.py>`.

- In a different terminal, run [ngrok](https://ngrok.com/) to make it publicly
  accessible.
- Use the publicly accessible url to integrate your bot with
  [Poe](https://poe.com/create_bot?server=1)

### Enable authentication

Poe servers send requests containing Authorization HTTP header in the format "Bearer
<access_key>"; the access key is configured in the bot settings page.

To validate that the request is from the Poe servers, you can either set the environment
variable POE_ACCESS_KEY or pass the parameter access_key in the run function like:

```python
if __name__ == "__main__":
    fp.run(EchoBot(), access_key=<key>)
```

## Samples

Check out our starter code
[repository](https://github.com/poe-platform/server-bot-quick-start) for some examples
you can use to get started with bot development.


================================================
FILE: docs/api_reference.md
================================================


The following is the API reference for the [fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes that you used `import fastapi_poe as fp`.

## `fp.PoeBot`

The class that you use to define your bot behavior. Once you define your PoeBot class, you
pass it to `make_app` to create a FastAPI app that serves your bot.

#### Parameters:
- `path` (`str = "/"`): This is the path at which your bot is served. By default, it's
set to "/" but this is something you can adjust. This is especially useful if you want to serve
multiple bots from one server.
- `access_key` (`Optional[str] = None`): This is the access key for your bot and when
provided is used to validate that the requests are coming from a trusted source. This access key
should be the same one that you provide when integrating your bot with Poe at:
https://poe.com/create_bot?server=1. You can also set this to None but certain features like
file output that mandate an `access_key` will not be available for your bot.
- `should_insert_attachment_messages` (`bool = True`): A flag to decide whether to parse out
content from attachments and insert them as messages into the conversation. This is set to
`True` by default and we recommend leaving on since it allows your bot to comprehend attachments
uploaded by users by default.
- `concat_attachments_to_message` (`bool = False`): **DEPRECATED**: Please set
`should_insert_attachment_messages` instead.

### `PoeBot.get_response`

Override this to define your bot's response given a user query.
#### Parameters:
- `request` (`QueryRequest`): an object representing the chat response request from Poe.
This will contain information about the chat state among other things.

#### Returns:
- `AsyncIterable[PartialResponse]`: objects representing your
response to the Poe servers. This is what gets displayed to the user.

Example usage:
```python
async def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]:
    last_message = request.query[-1].content
    yield fp.PartialResponse(text=last_message)
```

### `PoeBot.get_response_with_context`

A version of `get_response` that also includes the request context information. By
default, this will call `get_response`.
#### Parameters:
- `request` (`QueryRequest`): an object representing the chat response request from Poe.
This will contain information about the chat state among other things.
- `context` (`RequestContext`): an object representing the current HTTP request.

#### Returns:
- `AsyncIterable[Union[PartialResponse, ErrorResponse]]`: objects representing your
response to the Poe servers. This is what gets displayed to the user.

### `PoeBot.get_settings`

Override this to define your bot's settings.

#### Parameters:
- `setting` (`SettingsRequest`): An object representing the settings request.

#### Returns:
- `SettingsResponse`: An object representing the settings you want to use for your bot.

### `PoeBot.get_settings_with_context`

A version of `get_settings` that also includes the request context information. By
default, this will call `get_settings`.

#### Parameters:
- `setting` (`SettingsRequest`): An object representing the settings request.
- `context` (`RequestContext`): an object representing the current HTTP request.

#### Returns:
- `SettingsResponse`: An object representing the settings you want to use for your bot.

### `PoeBot.on_feedback`

Override this to record feedback from the user.
#### Parameters:
- `feedback_request` (`ReportFeedbackRequest`): An object representing the Feedback request
from Poe. This is sent out when a user provides feedback on a response on your bot.
#### Returns: `None`

### `PoeBot.on_feedback_with_context`

A version of `on_feedback` that also includes the request context information. By
default, this will call `on_feedback`.

#### Parameters:
- `feedback_request` (`ReportFeedbackRequest`): An object representing a feedback request
from Poe. This is sent out when a user provides feedback on a response on your bot.
- `context` (`RequestContext`): an object representing the current HTTP request.
#### Returns: `None`

### `PoeBot.on_reaction_with_context`

Override this to record a reaction from the user. This also includes the request context.

#### Parameters:
- `reaction_request` (`ReportReactionRequest`): An object representing a reaction request
from Poe. This is sent out when a user provides reaction on a response on your bot.
- `context` (`RequestContext`): an object representing the current HTTP request.
#### Returns: `None`

### `PoeBot.on_error`

Override this to record errors from the Poe server.
#### Parameters:
- `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.
This is sent out when the Poe server runs into an issue processing the response from your
bot.
#### Returns: `None`

### `PoeBot.on_error_with_context`

A version of `on_error` that also includes the request context information. By
default, this will call `on_error`.

#### Parameters:
- `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.
This is sent out when the Poe server runs into an issue processing the response from your
bot.
- `context` (`RequestContext`): an object representing the current HTTP request.
#### Returns: `None`

### `PoeBot.post_message_attachment`

Used to output an attachment in your bot's response.

#### Parameters:
- `message_id` (`Identifier`): The message id associated with the current QueryRequest.
- `download_url` (`Optional[str] = None`): A url to the file to be attached to the message.
- `download_filename` (`Optional[str] = None`): A filename to be used when storing the
downloaded attachment. If not set, the filename from the `download_url` is used.
- `file_data` (`Optional[Union[bytes, BinaryIO]] = None`): The contents of the file to be
uploaded. This should be a bytes-like or file object.
- `filename` (`Optional[str] = None`): The name of the file to be attached.
- `access_key` (`str`): **DEPRECATED**: Please set the access_key when creating the Bot
object instead.
#### Returns:
- `AttachmentUploadResponse`

**Note**: You need to provide either the `download_url` or both of `file_data` and
`filename`.

### `PoeBot.concat_attachment_content_to_message_body`

**DEPRECATED**: This method is deprecated. Use `insert_attachment_messages` instead.

Concatenate received attachment file content into the message body. This will be called
by default if `concat_attachments_to_message` is set to `True` but can also be used
manually if needed.

#### Parameters:
- `query_request` (`QueryRequest`): the request object from Poe.
#### Returns:
- `QueryRequest`: the request object after the attachments are unpacked and added to the
message body.

### `PoeBot.insert_attachment_messages`

Insert messages containing the contents of each user attachment right before the last user
message. This ensures the bot can consider all relevant information when generating a
response. This will be called by default if `should_insert_attachment_messages` is set to
`True` but can also be used manually if needed.

#### Parameters:
- `query_request` (`QueryRequest`): the request object from Poe.
#### Returns:
- `QueryRequest`: the request object after the attachments are unpacked and added to the
message body.

### `PoeBot.make_prompt_author_role_alternated`

Concatenate consecutive messages from the same author into a single message. This is useful
for LLMs that require role alternation between user and bot messages.

#### Parameters:
- `protocol_messages` (`Sequence[ProtocolMessage]`): the messages to make alternated.
#### Returns:
- `Sequence[ProtocolMessage]`: the modified messages.

### `PoeBot.capture_cost`

Used to capture variable costs for monetized and eligible bot creators.
Visit https://creator.poe.com/docs/creator-monetization for more information.

#### Parameters:
- `request` (`QueryRequest`): The currently handled QueryRequest object.
- `amounts` (`Union[list[CostItem], CostItem]`): The to be captured amounts.

#### Returns: `None`

### `PoeBot.authorize_cost`

Used to authorize a cost for monetized and eligible bot creators.
Visit https://creator.poe.com/docs/creator-monetization for more information.

#### Parameters:
- `request` (`QueryRequest`): The currently handled QueryRequest object.
- `amounts` (`Union[list[CostItem], CostItem]`): The to be authorized amounts.

#### Returns: `None`



---

## `fp.make_app`

Create an app object for your bot(s).

#### Parameters:
- `bot` (`Union[PoeBot, Sequence[PoeBot]]`): A bot object or a list of bot objects if you want
to host multiple bots on one server.
- `access_key` (`str = ""`): The access key to use.  If not provided, the server tries to
read the POE_ACCESS_KEY environment variable. If that is not set, the server will
refuse to start, unless `allow_without_key` is True. If multiple bots are provided,
the access key must be provided as part of the bot object.
- `bot_name` (`str = ""`): The name of the bot as it appears on poe.com.
- `api_key` (`str = ""`): **DEPRECATED**: Please set the access_key when creating the Bot
object instead.
- `allow_without_key` (`bool = False`): If True, the server will start even if no access
key is provided. Requests will not be checked against any key. If an access key is provided, it
is still checked.
- `app` (`Optional[FastAPI] = None`): A FastAPI app instance. If provided, the app will be
configured with the provided bots, access keys, and other settings. If not provided, a new
FastAPI application instance will be created and configured.
#### Returns:
- `FastAPI`: A FastAPI app configured to serve your bot when run.



---

## `fp.run`

Serve a poe bot using a FastAPI app. This function should be used when you are running the
bot locally. The parameters are the same as they are for `make_app`.

#### Returns: `None`



---

## `fp.stream_request`

The Entry point for the Bot Query API. This API allows you to use other bots on Poe for
inference in response to a user message. For more details, checkout:
https://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe

#### Parameters:
- `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object
also includes information needed to identify the user for compute point usage.
- `bot_name` (`str`): The bot you want to invoke.
- `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. You will need
this in case you are trying to use this function from a script/shell. Note that if an `api_key`
is provided, compute points will be charged on the account corresponding to the `api_key`.
- tools: (`Optional[list[ToolDefinition]] = None`): A list of ToolDefinition objects describing
the functions you have. This is used for OpenAI function calling.
- tool_executables: (`Optional[list[Callable]] = None`): A list of functions corresponding
to the ToolDefinitions. This is used for OpenAI function calling. When this is set, the
LLM-suggested tools will automatically run once, before passing the results back to the LLM for
a final response.



---

## `fp.get_bot_response`

Use this function to invoke another Poe bot from your shell.

#### Parameters:
- `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.
- `bot_name` (`str`): The bot that you want to invoke.
- `api_key` (`str`): Your Poe API key. Available at [poe.com/api_key](https://poe.com/api_key)
- `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects
describing the functions you have. This is used for OpenAI function calling.
- `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding
to the ToolDefinitions. This is used for OpenAI function calling.
- `temperature` (`Optional[float] = None`): The temperature to use for the bot.
- `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.
- `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.
- `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.
- `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is
mainly for internal testing and is not expected to be changed.
- `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.



---

## `fp.get_bot_response_sync`

This function wraps the async generator `fp.get_bot_response` and returns
partial responses synchronously.

For asynchronous streaming, or integration into an existing event loop, use
`fp.get_bot_response` directly.

#### Parameters:
- `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.
- `bot_name` (`str`): The bot that you want to invoke.
- `api_key` (`str`): Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key)
- `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects
describing the functions you have. This is used for OpenAI function calling.
- `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding
to the ToolDefinitions. This is used for OpenAI function calling.
- `temperature` (`Optional[float] = None`): The temperature to use for the bot.
- `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.
- `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.
- `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.
- `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is
mainly for internal testing and is not expected to be changed.
- `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.



---

## `fp.get_final_response`

A helper function for the bot query API that waits for all the tokens and concatenates the full
response before returning.

#### Parameters:
- `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object
also includes information needed to identify the user for compute point usage.
- `bot_name` (`str`): The bot you want to invoke.
- `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. You will need this in
case you are trying to use this function from a script/shell. Note that if an `api_key` is
provided, compute points will be charged on the account corresponding to the `api_key`.



---

## `fp.upload_file`

Upload a file (raw bytes *or* via URL) to Poe and receive an Attachment
object that can be returned directly from a bot or stored for later use.

#### Parameters:
- `file` (`Optional[Union[bytes, BinaryIO]] = None`): The file to upload.
- `file_url` (`Optional[str] = None`): The URL of the file to upload.
- `file_name` (`Optional[str] = None`): The name of the file to upload. Required if
`file` is provided as raw bytes.
- `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. This can
also be the `access_key` if called from a Poe server bot.

#### Returns:
- `Attachment`: An Attachment object representing the uploaded file.



---

## `fp.upload_file_sync`

This is a synchronous wrapper around the async `upload_file`.



---

## `fp.QueryRequest`

Request parameters for a query request.
#### Fields:
- `query` (`list[ProtocolMessage]`): list of message representing the current state of the chat.
- `user_id` (`Identifier`): an anonymized identifier representing a user. This is persistent
for subsequent requests from that user.
- `conversation_id` (`Identifier`): an identifier representing a chat. This is
persistent for subsequent request for that chat.
- `message_id` (`Identifier`): an identifier representing a message.
- `access_key` (`str = "<missing>"`): contains the access key defined when you created your bot
on Poe.
- `temperature` (`float | None = None`): Temperature input to be used for model inference.
- `skip_system_prompt` (`bool = False`): Whether to use any system prompting or not.
- `logit_bias` (`dict[str, float] = {}`)
- `stop_sequences` (`list[str] = []`)
- `language_code` (`str = "en"`): BCP 47 language code of the user's client.
- `bot_query_id` (`str = ""`): an identifier representing a bot query.
- `users` (`list[User] = []`): list of users in the chat.



---

## `fp.ProtocolMessage`

A message as used in the Poe protocol.
#### Fields:
- `role` (`Literal["system", "user", "bot", "tool"]`): Message sender role.
- `message_type` (`Optional[MessageType] = None`): Type of the message.
- `sender_id` (`Optional[str]`): Sender ID of the message. This is deprecated, use
  `sender` instead.
- `sender` (`Optional[Sender] = None`): Sender of the message.
- `content` (`str`): Content of the message.
- `parameters` (`dict[str, Any] = {}`): Parameters for the message.
- `content_type` (`ContentType="text/markdown"`): Content type of the message.
- `timestamp` (`int = 0`): Timestamp of the message.
- `message_id` (`str = ""`): Message ID for the message.
- `feedback` (`list[MessageFeedback] = []`): Feedback for the message.
- `attachments` (`list[Attachment] = []`): Attachments for the message.
- `metadata` (`Optional[str] = None`): Metadata associated with the message.
- `referenced_message` (`Optional["ProtocolMessage"] = None`): Message referenced by
  this message (if any).
- `reactions` (`list[MessageReaction] = []`): Reactions to the message.



---

## `fp.Sender`

Sender of a message.
#### Fields:
- `id` (`Optional[Identifier] = None`): An anonymized identifier representing the sender.
- `name` (`Optional[str] = None`): The name of the sender.
If sender is a bot, this will be the name of the bot.
If sender is a user, this will be the name of the user if user name is available for this chat.
Typically, user name is only available in a chat of multiple users. Please note that a user
can change their name anytime and different users with different `id` can share the same name.



---

## `fp.User`

User in a chat.
#### Fields:
- `id` (`Identifier`): An anonymized identifier representing a user.
- `name` (`Optional[str] = None`): The name of the user if user name is available for this chat.
Typically, user name is only available in a chat of multiple users. Please note that a user
can change their name anytime and different users with different `id` can share the same name.



---

## `fp.MessageReaction`

Reaction to a message.
#### Fields:
- `user_id` (`Identifier`): An anonymized identifier representing the
user who reacted to the message.
- `reaction` (`str`): The reaction to the message.



---

## `fp.PartialResponse`

Representation of a (possibly partial) response from a bot. Yield this in
`PoeBot.get_response` or `PoeBot.get_response_with_context` to communicate your response to Poe.

#### Fields:
- `text` (`str`): The actual text you want to display to the user. Note that this should solely
be the text in the next token since Poe will automatically concatenate all tokens before
displaying the response to the user.
- `data` (`Optional[dict[str, Any]]`): Used to send arbitrary json data to Poe. This is
currently only used for OpenAI function calling.
- `is_suggested_reply` (`bool = False`): Setting this to true will create a suggested reply with
the provided text value.
- `is_replace_response` (`bool = False`): Setting this to true will clear out the previously
displayed text to the user and replace it with the provided text value.



---

## `fp.ErrorResponse`

Similar to `PartialResponse`. Yield this to communicate errors from your bot.

#### Fields:
- `allow_retry` (`bool = True`): Whether or not to allow a user to retry on error.
- `error_type` (`Optional[ErrorType] = None`): An enum indicating what error to display.



---

## `fp.MetaResponse`

Similar to `Partial Response`. Yield this to communicate `meta` events from server bots.

#### Fields:
- `suggested_replies` (`bool = False`): Whether or not to enable suggested replies.
- `content_type` (`ContentType = "text/markdown"`): Used to describe the format of the response.
The currently supported values are `text/plain` and `text/markdown`.
- `refetch_settings` (`bool = False`): Used to trigger a settings fetch request from Poe. A more
robust way to trigger this is documented at:
https://creator.poe.com/docs/server-bots/updating-bot-settings



---

## `fp.DataResponse`

A response that contains arbitrary data to attach to the bot response.
This data can be retrieved in later requests to the bot within the same chat.
Note that only the final DataResponse object in the stream will be attached to the bot response.

#### Fields:
- `metadata` (`str`): String of data to attach to the bot response.



---

## `fp.AttachmentUploadResponse`

The result of a post_message_attachment request.
#### Fields:
- `attachment_url` (`Optional[str]`): The URL of the attachment.
- `mime_type` (`Optional[str]`): The MIME type of the attachment.
- `inline_ref` (`Optional[str]`): The inline reference of the attachment.
if post_message_attachment is called with is_inline=False, this will be None.



---

## `fp.SettingsRequest`

Request parameters for a settings request. Currently, this contains no fields but this
might get updated in the future.



---

## `fp.SettingsResponse`

An object representing your bot's response to a settings object.
#### Fields:
- `response_version` (`int = 2`): Different Poe Protocol versions use different default settings
values. When provided, Poe will use the default values for the specified response version.
If not provided, Poe will use the default values for response version 0.
- `server_bot_dependencies` (`dict[str, int] = {}`): Information about other bots that your bot
uses. This is used to facilitate the Bot Query API.
- `allow_attachments` (`bool = True`): Whether to allow users to upload attachments to your
bot.
- `introduction_message` (`str = ""`): The introduction message to display to the users of your
bot.
- `expand_text_attachments` (`bool = True`): Whether to request parsed content/descriptions from
text attachments with the query request. This content is sent through the new parsed_content
field in the attachment dictionary. This change makes enabling file uploads much simpler.
- `enable_image_comprehension` (`bool = False`): Similar to `expand_text_attachments` but for
images.
- `enforce_author_role_alternation` (`bool = False`): If enabled, Poe will concatenate messages
so that they follow role alternation, which is a requirement for certain LLM providers like
Anthropic.
 - `enable_multi_entity_prompting` (`bool = True`): If enabled, Poe will combine previous bot
 messages if there is a multientity context.
- `parameter_controls` (`Optional[ParameterControls] = None`): Optional JSON object that defines
interactive parameter controls. The object must contain an api_version and sections array.



---

## `fp.ReportFeedbackRequest`

Request parameters for a report_feedback request.
#### Fields:
- `message_id` (`Identifier`)
- `user_id` (`Identifier`)
- `conversation_id` (`Identifier`)
- `feedback_type` (`FeedbackType`)



---

## `fp.ReportReactionRequest`

Request parameters for a report_reaction request.
#### Fields:
- `message_id` (`Identifier`)
- `user_id` (`Identifier`)
- `conversation_id` (`Identifier`)
- `reaction` (`str`)



---

## `fp.ReportErrorRequest`

Request parameters for a report_error request.
#### Fields:
- `message` (`str`)
- `metadata` (`dict[str, Any]`)



---

## `fp.Attachment`

Attachment included in a protocol message.
#### Fields:
- `url` (`str`): The download URL of the attachment.
- `content_type` (`str`): The MIME type of the attachment.
- `name` (`str`): The name of the attachment.
- `inline_ref` (`Optional[str] = None`): Set this to make Poe render the attachment inline.
    You can then reference the attachment inline using ![title][inline_ref].
- `parsed_content` (`Optional[str] = None`): The parsed content of the attachment.



---

## `fp.MessageFeedback`

Feedback for a message as used in the Poe protocol.
#### Fields:
- `type` (`FeedbackType`)
- `reason` (`Optional[str]`)



---

## `fp.ToolDefinition`

An object representing a tool definition used for OpenAI function calling.
#### Fields:
- `type` (`str`)
- `function` (`FunctionDefinition`): Look at the source code for a detailed description
of what this means.



---

## `fp.ToolCallDefinition`

An object representing a tool call. This is returned as a response by the model when using
OpenAI function calling.
#### Fields:
- `id` (`str`)
- `type` (`str`)
- `function` (`FunctionDefinition`): The function name (string) and arguments (JSON string).



---

## `fp.ToolResultDefinition`

An object representing a function result. This is passed to the model in the last step
when using OpenAI function calling.
#### Fields:
- `role` (`str`)
- `name` (`str`)
- `tool_call_id` (`str`)
- `content` (`str`)


================================================
FILE: docs/generate_api_reference.py
================================================
"""

- To generate reference documentation:
  - Add/update docstrings in the codebase. If you are adding a new class/function, add
    it's name to `documented_items` in `docs/generate_api_reference.py`
  - Install local version of fastapi_poe: `pip install -e .`
  - run `python3 generate_api_reference.py`
  - [Internal only] Copy the contents of `api_reference.md` to the reference page in
    README.

"""

import inspect
import sys
import types
from dataclasses import dataclass, field
from typing import Callable, Optional, Union

sys.path.append("../src")
import fastapi_poe

INITIAL_TEXT = """

The following is the API reference for the \
[fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. The reference assumes \
that you used `import fastapi_poe as fp`.

"""


@dataclass
class DocumentationData:
    name: str
    docstring: Optional[str]
    data_type: str
    children: list = field(default_factory=lambda: [])


def _unwrap_func(func_obj: Union[staticmethod, Callable]) -> Callable:
    """Grab the underlying func_obj."""
    if isinstance(func_obj, staticmethod):
        return _unwrap_func(func_obj.__func__)
    return func_obj


def get_documentation_data(
    *, module: types.ModuleType, documented_items: list[str]
) -> dict[str, DocumentationData]:
    data_dict = {}
    for name, obj in inspect.getmembers(module):
        if (
            inspect.isclass(obj) or inspect.isfunction(obj)
        ) and name in documented_items:
            doc = inspect.getdoc(obj)
            data_type = "class" if inspect.isclass(obj) else "function"
            dd_obj = DocumentationData(name=name, docstring=doc, data_type=data_type)

            if inspect.isclass(obj):
                children = []
                # for func_name, func_obj in inspect.getmembers(obj, inspect.isfunction):
                for func_name, func_obj in obj.__dict__.items():
                    if not inspect.isfunction(func_obj):
                        continue
                    if not func_name.startswith("_"):
                        func_obj = _unwrap_func(func_obj)
                        func_doc = inspect.getdoc(func_obj)
                        children.append(
                            DocumentationData(
                                name=func_name, docstring=func_doc, data_type="function"
                            )
                        )
                dd_obj.children = children
            data_dict[name] = dd_obj
    return data_dict


def generate_documentation(
    *,
    data_dict: dict[str, DocumentationData],
    documented_items: list[str],
    output_filename: str,
) -> None:
    # reset the file first
    with open(output_filename, "w") as f:
        f.write("")

    with open(output_filename, "w") as f:
        f.write(INITIAL_TEXT)

        first = True
        for item in documented_items:
            if first is True:
                first = False
            else:
                f.write("---\n\n")
            item_data = data_dict[item]
            f.write(f"## `fp.{item_data.name}`\n\n")
            f.write(f"{item_data.docstring}\n\n")
            for child in item_data.children:
                if not child.docstring:
                    continue
                f.write(f"### `{item}.{child.name}`\n\n")
                f.write(f"{child.docstring}\n\n")
            f.write("\n\n")


# Specify the names of classes and functions to document
documented_items = [
    "PoeBot",
    "make_app",
    "run",
    "stream_request",
    "get_bot_response",
    "get_bot_response_sync",
    "get_final_response",
    "upload_file",
    "upload_file_sync",
    "QueryRequest",
    "ProtocolMessage",
    "Sender",
    "User",
    "MessageReaction",
    "PartialResponse",
    "ErrorResponse",
    "MetaResponse",
    "DataResponse",
    "AttachmentUploadResponse",
    "SettingsRequest",
    "SettingsResponse",
    "ReportFeedbackRequest",
    "ReportReactionRequest",
    "ReportErrorRequest",
    "Attachment",
    "MessageFeedback",
    "ToolDefinition",
    "ToolCallDefinition",
    "ToolResultDefinition",
]

data_dict = get_documentation_data(
    module=fastapi_poe, documented_items=documented_items
)
output_filename = "api_reference.md"
generate_documentation(
    data_dict=data_dict,
    documented_items=documented_items,
    output_filename=output_filename,
)


================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "fastapi_poe"
version = "0.0.83"
authors = [
  { name="Yusheng Ding", email="yding@quora.com" },
  { name="Kris Yang", email="kryang@quora.com" },
  { name="John Li", email="jli@quora.com" },
]
description = "A demonstration of the Poe protocol using FastAPI"
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
    "Programming Language :: Python :: 3",
    "License :: OSI Approved :: Apache Software License",
    "Operating System :: OS Independent",
]
dependencies = [
    "fastapi",
    "sse-starlette>=2.2.1",
    "typing-extensions>=4.5.0",
    "uvicorn",
    "httpx",
    "httpx-sse",
    "pydantic>2",
]
[project.optional-dependencies]
dev = [
    "pytest",
    "pytest-cov",
    "pytest-asyncio",
]

[project.urls]
"Homepage" = "https://creator.poe.com/"

[tool.pyright]
pythonVersion = "3.9"

[tool.pytest.ini_options]
addopts = "--cov=src/fastapi_poe --cov-fail-under=80"
required_plugins = ["pytest-cov"]

[tool.black]
target-version = ['py39']
skip-magic-trailing-comma = true

[tool.ruff]
lint.select = [
  "F",
  "E",
  "I",  # import sorting
  "ANN",  # type annotations for everything
  "C4",  # flake8-comprehensions
  "B",  # bugbear
  "SIM",  # simplify
  "UP",  # pyupgrade
  "PIE810",  # startswith/endswith with a tuple
  "SIM101",  # mergeable isinstance() calls
  "SIM201",  # "not ... == ..." -> "... != ..."
  "SIM202",  # "not ... != ..." -> "... == ..."
  "C400",  # unnecessary list() calls
  "C401",  # unnecessary set() calls
  "C402",  # unnecessary dict() calls
  "C403",  # unnecessary listcomp within set() call
  "C404",  # unnecessary listcomp within dict() call
  "C405",  # use set literals
  "C406",  # use dict literals
  "C409",  # tuple() calls that can be replaced with literals
  "C410",  # list() calls that can be replaced with literals
  "C411",  # list() calls with genexps that can be replaced with listcomps
  "C413",  # unnecessary list() calls around sorted()
  "C414",  # unnecessary list() calls inside sorted()
  "C417",  # unnecessary map() calls that can be replaced with listcomps/genexps
  "C418",  # unnecessary dict() calls that can be replaced with literals
  "PERF101",  # unnecessary list() calls that can be replaced with literals
]

lint.ignore = [
  "B008",  # do not perform function calls in argument defaults
  "ANN101",  # missing type annotation for self in method
  "ANN102",  # missing type annotation for cls in classmethod
]

line-length = 100
target-version = "py39"


================================================
FILE: src/fastapi_poe/__init__.py
================================================
__all__ = [
    "PoeBot",
    "run",
    "make_app",
    "stream_request",
    "get_bot_response",
    "get_bot_response_sync",
    "get_final_response",
    "BotError",
    "BotErrorNoRetry",
    "Attachment",
    "ProtocolMessage",
    "Sender",
    "User",
    "MessageReaction",
    "QueryRequest",
    "SettingsRequest",
    "ReportFeedbackRequest",
    "ReportReactionRequest",
    "ReportErrorRequest",
    "SettingsResponse",
    "PartialResponse",
    "ErrorResponse",
    "MetaResponse",
    "DataResponse",
    "AttachmentUploadResponse",
    "RequestContext",
    "ToolDefinition",
    "ToolCallDefinition",
    "ToolResultDefinition",
    "MessageFeedback",
    "sync_bot_settings",
    "CostItem",
    "InsufficientFundError",
    "CostRequestError",
    "upload_file",
    "upload_file_sync",
    "ParameterControls",
    "Section",
    "Tab",
    "FullControls",
    "ConditionallyRenderControls",
    "ComparatorCondition",
    "ParameterValue",
    "LiteralValue",
    "BaseControl",
    "AspectRatio",
    "AspectRatioOption",
    "Slider",
    "ToggleSwitch",
    "DropDown",
    "ValueNamePair",
    "TextArea",
    "TextField",
    "Divider",
    "Number",
]

from .base import CostRequestError, InsufficientFundError, PoeBot, make_app, run
from .client import (
    BotError,
    BotErrorNoRetry,
    get_bot_response,
    get_bot_response_sync,
    get_final_response,
    stream_request,
    sync_bot_settings,
    upload_file,
    upload_file_sync,
)
from .types import (
    AspectRatio,
    AspectRatioOption,
    Attachment,
    AttachmentUploadResponse,
    BaseControl,
    ComparatorCondition,
    ConditionallyRenderControls,
    CostItem,
    DataResponse,
    Divider,
    DropDown,
    ErrorResponse,
    FullControls,
    LiteralValue,
    MessageFeedback,
    MessageReaction,
    MetaResponse,
    Number,
    ParameterControls,
    ParameterValue,
    PartialResponse,
    ProtocolMessage,
    QueryRequest,
    ReportErrorRequest,
    ReportFeedbackRequest,
    ReportReactionRequest,
    RequestContext,
    Section,
    Sender,
    SettingsRequest,
    SettingsResponse,
    Slider,
    Tab,
    TextArea,
    TextField,
    ToggleSwitch,
    ToolCallDefinition,
    ToolDefinition,
    ToolResultDefinition,
    User,
    ValueNamePair,
)


================================================
FILE: src/fastapi_poe/base.py
================================================
import argparse
import asyncio
import copy
import json
import logging
import os
import random
import string
import sys
import warnings
from collections import defaultdict
from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass
from typing import BinaryIO, Callable, Optional, Union
from urllib.parse import unquote, urlparse

import httpx
import httpx_sse
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sse_starlette.event import ServerSentEvent
from sse_starlette.sse import EventSourceResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import Message
from typing_extensions import deprecated, overload

from fastapi_poe.client import PROTOCOL_VERSION, sync_bot_settings, upload_file
from fastapi_poe.templates import (
    IMAGE_VISION_ATTACHMENT_TEMPLATE,
    TEXT_ATTACHMENT_TEMPLATE,
    URL_ATTACHMENT_TEMPLATE,
)
from fastapi_poe.types import (
    Attachment,
    AttachmentUploadResponse,
    ContentType,
    CostItem,
    DataResponse,
    ErrorResponse,
    Identifier,
    MetaResponse,
    PartialResponse,
    ProtocolMessage,
    QueryRequest,
    ReportErrorRequest,
    ReportFeedbackRequest,
    ReportReactionRequest,
    RequestContext,
    Sender,
    SettingsRequest,
    SettingsResponse,
)

logger = logging.getLogger("uvicorn.default")
POE_API_WEBSERVER_BASE_URL = "https://www.quora.com/poe_api/"


class InvalidParameterError(Exception):
    pass


class CostRequestError(Exception):
    pass


class InsufficientFundError(Exception):
    pass


class LoggingMiddleware(BaseHTTPMiddleware):  # pragma: no cover
    async def set_body(self, request: Request) -> None:
        receive_ = await request._receive()

        async def receive() -> Message:
            return receive_

        request._receive = receive

    async def dispatch(
        self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
    ) -> Response:
        logger.info(f"Request: {request.method} {request.url}")
        try:
            # Per https://github.com/tiangolo/fastapi/issues/394#issuecomment-927272627
            # to avoid blocking.
            await self.set_body(request)
            body = await request.json()
            logger.debug(f"Request body: {json.dumps(body)}")
        except json.JSONDecodeError:
            logger.error("Request body: Unable to parse JSON")

        response = await call_next(request)

        logger.info(f"Response status: {response.status_code}")
        try:
            if hasattr(response, "body"):
                body = json.loads(bytes(response.body).decode())
                logger.debug(f"Response body: {json.dumps(body)}")
        except json.JSONDecodeError:
            logger.error("Response body: Unable to parse JSON")

        return response


async def http_exception_handler(request: Request, ex: Exception) -> Response:
    logger.error(ex)
    return Response(status_code=500, content="Internal server error")


http_bearer = HTTPBearer()


def generate_inline_ref() -> str:
    return "".join(random.choices(string.ascii_letters + string.digits, k=8))


def get_filename_from_url(url: str) -> str:
    parsed_url = urlparse(url)
    filename = os.path.basename(parsed_url.path)
    filename = unquote(filename)
    return filename or "downloaded_file"


@dataclass
class PoeBot:
    """

    The class that you use to define your bot behavior. Once you define your PoeBot class, you
    pass it to `make_app` to create a FastAPI app that serves your bot.

    #### Parameters:
    - `path` (`str = "/"`): This is the path at which your bot is served. By default, it's
    set to "/" but this is something you can adjust. This is especially useful if you want to serve
    multiple bots from one server.
    - `access_key` (`Optional[str] = None`): This is the access key for your bot and when
    provided is used to validate that the requests are coming from a trusted source. This access key
    should be the same one that you provide when integrating your bot with Poe at:
    https://poe.com/create_bot?server=1. You can also set this to None but certain features like
    file output that mandate an `access_key` will not be available for your bot.
    - `should_insert_attachment_messages` (`bool = True`): A flag to decide whether to parse out
    content from attachments and insert them as messages into the conversation. This is set to
    `True` by default and we recommend leaving on since it allows your bot to comprehend attachments
    uploaded by users by default.
    - `concat_attachments_to_message` (`bool = False`): **DEPRECATED**: Please set
    `should_insert_attachment_messages` instead.

    """

    path: str = "/"  # Path where this bot will be exposed
    access_key: Optional[str] = None  # Access key for this bot
    bot_name: Optional[str] = None  # Name of the bot using this PoeBot instance in Poe
    should_insert_attachment_messages: bool = (
        True  # Whether to insert attachment messages into the conversation
    )
    concat_attachments_to_message: bool = False  # Deprecated

    # Override these for your bot
    async def get_response(
        self, request: QueryRequest
    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:
        """

        Override this to define your bot's response given a user query.
        #### Parameters:
        - `request` (`QueryRequest`): an object representing the chat response request from Poe.
        This will contain information about the chat state among other things.

        #### Returns:
        - `AsyncIterable[PartialResponse]`: objects representing your
        response to the Poe servers. This is what gets displayed to the user.

        Example usage:
        ```python
        async def get_response(self, request: fp.QueryRequest) -> AsyncIterable[fp.PartialResponse]:
            last_message = request.query[-1].content
            yield fp.PartialResponse(text=last_message)
        ```

        """
        yield self.text_event("hello")

    async def get_response_with_context(
        self, request: QueryRequest, context: RequestContext
    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:
        """

        A version of `get_response` that also includes the request context information. By
        default, this will call `get_response`.
        #### Parameters:
        - `request` (`QueryRequest`): an object representing the chat response request from Poe.
        This will contain information about the chat state among other things.
        - `context` (`RequestContext`): an object representing the current HTTP request.

        #### Returns:
        - `AsyncIterable[Union[PartialResponse, ErrorResponse]]`: objects representing your
        response to the Poe servers. This is what gets displayed to the user.

        """
        try:
            async for event in self.get_response(request):
                yield event
        except InsufficientFundError:
            yield ErrorResponse(error_type="insufficient_fund", text="")

    async def get_settings(self, setting: SettingsRequest) -> SettingsResponse:
        """

        Override this to define your bot's settings.

        #### Parameters:
        - `setting` (`SettingsRequest`): An object representing the settings request.

        #### Returns:
        - `SettingsResponse`: An object representing the settings you want to use for your bot.

        """
        return SettingsResponse()

    async def get_settings_with_context(
        self, setting: SettingsRequest, context: RequestContext
    ) -> SettingsResponse:
        """

        A version of `get_settings` that also includes the request context information. By
        default, this will call `get_settings`.

        #### Parameters:
        - `setting` (`SettingsRequest`): An object representing the settings request.
        - `context` (`RequestContext`): an object representing the current HTTP request.

        #### Returns:
        - `SettingsResponse`: An object representing the settings you want to use for your bot.

        """
        settings = await self.get_settings(setting)
        return settings

    async def on_feedback(self, feedback_request: ReportFeedbackRequest) -> None:
        """

        Override this to record feedback from the user.
        #### Parameters:
        - `feedback_request` (`ReportFeedbackRequest`): An object representing the Feedback request
        from Poe. This is sent out when a user provides feedback on a response on your bot.
        #### Returns: `None`

        """
        pass

    async def on_feedback_with_context(
        self, feedback_request: ReportFeedbackRequest, context: RequestContext
    ) -> None:
        """

        A version of `on_feedback` that also includes the request context information. By
        default, this will call `on_feedback`.

        #### Parameters:
        - `feedback_request` (`ReportFeedbackRequest`): An object representing a feedback request
        from Poe. This is sent out when a user provides feedback on a response on your bot.
        - `context` (`RequestContext`): an object representing the current HTTP request.
        #### Returns: `None`

        """
        await self.on_feedback(feedback_request)

    async def on_reaction_with_context(
        self, reaction_request: ReportReactionRequest, context: RequestContext
    ) -> None:
        """

        Override this to record a reaction from the user. This also includes the request context.

        #### Parameters:
        - `reaction_request` (`ReportReactionRequest`): An object representing a reaction request
        from Poe. This is sent out when a user provides reaction on a response on your bot.
        - `context` (`RequestContext`): an object representing the current HTTP request.
        #### Returns: `None`

        """
        pass

    async def on_error(self, error_request: ReportErrorRequest) -> None:
        """

        Override this to record errors from the Poe server.
        #### Parameters:
        - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.
        This is sent out when the Poe server runs into an issue processing the response from your
        bot.
        #### Returns: `None`

        """
        logger.error(f"Error from Poe server: {error_request}")

    async def on_error_with_context(
        self, error_request: ReportErrorRequest, context: RequestContext
    ) -> None:
        """

        A version of `on_error` that also includes the request context information. By
        default, this will call `on_error`.

        #### Parameters:
        - `error_request` (`ReportErrorRequest`): An object representing an error request from Poe.
        This is sent out when the Poe server runs into an issue processing the response from your
        bot.
        - `context` (`RequestContext`): an object representing the current HTTP request.
        #### Returns: `None`

        """
        await self.on_error(error_request)

    # Helpers for generating responses
    def __post_init__(self) -> None:
        self._file_events_to_yield: dict[Identifier, list[ServerSentEvent]] = {}

    # This overload leaves access_key as the first argument, but is deprecated.
    @overload
    @deprecated(
        "The access_key and content_type parameters are deprecated. "
        "Set the access_key when creating the Bot object instead."
    )
    async def post_message_attachment(
        self,
        access_key: str,
        message_id: Identifier,
        *,
        download_url: Optional[str] = None,
        download_filename: Optional[str] = None,
        file_data: Optional[Union[bytes, BinaryIO]] = None,
        filename: Optional[str] = None,
        content_type: Optional[str] = None,
        is_inline: bool = False,
        base_url: str = POE_API_WEBSERVER_BASE_URL,
    ) -> AttachmentUploadResponse: ...

    # This overload requires all parameters to be passed as keywords
    @overload
    async def post_message_attachment(
        self,
        *,
        message_id: Identifier,
        download_url: Optional[str] = None,
        download_filename: Optional[str] = None,
        file_data: Optional[Union[bytes, BinaryIO]] = None,
        filename: Optional[str] = None,
        is_inline: bool = False,
        base_url: str = POE_API_WEBSERVER_BASE_URL,
    ) -> AttachmentUploadResponse: ...

    async def post_message_attachment(
        self,
        access_key: Optional[str] = None,
        message_id: Optional[Identifier] = None,
        *,
        download_url: Optional[str] = None,
        download_filename: Optional[str] = None,
        file_data: Optional[Union[bytes, BinaryIO]] = None,
        filename: Optional[str] = None,
        content_type: Optional[str] = None,
        is_inline: bool = False,
        base_url: str = POE_API_WEBSERVER_BASE_URL,
    ) -> AttachmentUploadResponse:
        """

        Used to output an attachment in your bot's response.

        #### Parameters:
        - `message_id` (`Identifier`): The message id associated with the current QueryRequest.
        - `download_url` (`Optional[str] = None`): A url to the file to be attached to the message.
        - `download_filename` (`Optional[str] = None`): A filename to be used when storing the
        downloaded attachment. If not set, the filename from the `download_url` is used.
        - `file_data` (`Optional[Union[bytes, BinaryIO]] = None`): The contents of the file to be
        uploaded. This should be a bytes-like or file object.
        - `filename` (`Optional[str] = None`): The name of the file to be attached.
        - `access_key` (`str`): **DEPRECATED**: Please set the access_key when creating the Bot
        object instead.
        #### Returns:
        - `AttachmentUploadResponse`

        **Note**: You need to provide either the `download_url` or both of `file_data` and
        `filename`.

        """

        assert message_id is not None, "message_id parameter is required"
        name = filename or download_filename
        if not name:
            if not download_url:
                raise InvalidParameterError(
                    "filename or download_url/download_filename required"
                )
            else:
                name = get_filename_from_url(download_url)

        if self.access_key:
            if access_key:
                warnings.warn(
                    "Bot already has an access key, access_key parameter is not needed.",
                    DeprecationWarning,
                    stacklevel=2,
                )
                attachment_access_key = access_key
            else:
                attachment_access_key = self.access_key
        else:
            if access_key is None:
                raise InvalidParameterError(
                    "access_key parameter is required if bot is not"
                    + " provided with an access_key when make_app is called."
                )
            attachment_access_key = access_key

        if content_type is not None:
            warnings.warn(
                "content_type parameter is deprecated, and will be removed in a future release.",
                DeprecationWarning,
                stacklevel=2,
            )

        attachment = await self._upload_file(
            file=file_data,
            file_url=download_url,
            file_name=filename or download_filename,
            api_key=attachment_access_key,
            base_url=base_url,
        )

        inline_ref = generate_inline_ref() if is_inline else None
        file_events_to_yield = self._file_events_to_yield.setdefault(message_id, [])

        assert name is not None  # we check this above, but pyright can't detect it
        file_events_to_yield.append(
            self.file_event(
                url=attachment.url,
                content_type=attachment.content_type,
                name=name,
                inline_ref=inline_ref,
            )
        )
        return AttachmentUploadResponse(
            attachment_url=attachment.url,
            mime_type=attachment.content_type,
            inline_ref=inline_ref,
        )

    async def _upload_file(
        self,
        *,
        file: Optional[Union[bytes, BinaryIO]],
        file_url: Optional[str],
        file_name: Optional[str],
        api_key: str,
        base_url: str,
    ) -> Attachment:
        return await upload_file(
            file=file,
            file_url=file_url,
            file_name=file_name,
            api_key=api_key,
            base_url=base_url,
        )

    @deprecated(
        "This method is deprecated. Use `insert_attachment_messages` instead."
        "This method will be removed in a future release."
    )
    def concat_attachment_content_to_message_body(
        self, query_request: QueryRequest
    ) -> QueryRequest:  # pragma: no cover
        """

        **DEPRECATED**: This method is deprecated. Use `insert_attachment_messages` instead.

        Concatenate received attachment file content into the message body. This will be called
        by default if `concat_attachments_to_message` is set to `True` but can also be used
        manually if needed.

        #### Parameters:
        - `query_request` (`QueryRequest`): the request object from Poe.
        #### Returns:
        - `QueryRequest`: the request object after the attachments are unpacked and added to the
        message body.

        """
        last_message = query_request.query[-1]
        concatenated_content = last_message.content
        for attachment in last_message.attachments:
            if attachment.parsed_content:
                if attachment.content_type == "text/html":
                    url_attachment_content = URL_ATTACHMENT_TEMPLATE.format(
                        attachment_name=attachment.name,
                        content=attachment.parsed_content,
                    )
                    concatenated_content = (
                        f"{concatenated_content}\n\n{url_attachment_content}"
                    )
                elif "text" in attachment.content_type:
                    text_attachment_content = TEXT_ATTACHMENT_TEMPLATE.format(
                        attachment_name=attachment.name,
                        attachment_parsed_content=attachment.parsed_content,
                    )
                    concatenated_content = (
                        f"{concatenated_content}\n\n{text_attachment_content}"
                    )
                elif "image" in attachment.content_type:
                    parsed_content_filename = attachment.parsed_content.split("***")[0]
                    parsed_content_text = attachment.parsed_content.split("***")[1]
                    image_attachment_content = IMAGE_VISION_ATTACHMENT_TEMPLATE.format(
                        filename=parsed_content_filename,
                        parsed_image_description=parsed_content_text,
                    )
                    concatenated_content = (
                        f"{concatenated_content}\n\n{image_attachment_content}"
                    )
        modified_last_message = last_message.model_copy(
            update={"content": concatenated_content}
        )
        modified_query = query_request.model_copy(
            update={"query": query_request.query[:-1] + [modified_last_message]}
        )
        return modified_query

    def insert_attachment_messages(self, query_request: QueryRequest) -> QueryRequest:
        """

        Insert messages containing the contents of each user attachment right before the last user
        message. This ensures the bot can consider all relevant information when generating a
        response. This will be called by default if `should_insert_attachment_messages` is set to
        `True` but can also be used manually if needed.

        #### Parameters:
        - `query_request` (`QueryRequest`): the request object from Poe.
        #### Returns:
        - `QueryRequest`: the request object after the attachments are unpacked and added to the
        message body.

        """
        last_message = query_request.query[-1]
        text_attachment_messages = []
        image_attachment_messages = []
        for attachment in last_message.attachments:
            if attachment.parsed_content:
                if attachment.content_type == "text/html":
                    url_attachment_content = URL_ATTACHMENT_TEMPLATE.format(
                        attachment_name=attachment.name,
                        content=attachment.parsed_content,
                    )
                    text_attachment_messages.append(
                        ProtocolMessage(
                            role="user", sender=Sender(), content=url_attachment_content
                        )
                    )
                elif (
                    attachment.content_type.startswith("text/")
                    or attachment.content_type == "application/pdf"
                ):
                    text_attachment_content = TEXT_ATTACHMENT_TEMPLATE.format(
                        attachment_name=attachment.name,
                        attachment_parsed_content=attachment.parsed_content,
                    )
                    text_attachment_messages.append(
                        ProtocolMessage(
                            role="user",
                            sender=Sender(),
                            content=text_attachment_content,
                        )
                    )
                elif "image" in attachment.content_type:
                    try:
                        # Poe currently sends analysis in the format of filename***analysis
                        parsed_content_filename, parsed_content_text = (
                            attachment.parsed_content.split("***", 1)
                        )
                    except ValueError:
                        # If the format is not filename***analysis, use the attachment filename
                        parsed_content_filename = attachment.name
                        parsed_content_text = attachment.parsed_content
                    image_attachment_content = IMAGE_VISION_ATTACHMENT_TEMPLATE.format(
                        filename=parsed_content_filename,
                        parsed_image_description=parsed_content_text,
                    )
                    image_attachment_messages.append(
                        ProtocolMessage(
                            role="user",
                            sender=Sender(),
                            content=image_attachment_content,
                        )
                    )
        modified_query = query_request.model_copy(
            update={
                "query": query_request.query[:-1]
                + text_attachment_messages
                + image_attachment_messages
                + [last_message]
            }
        )
        return modified_query

    def make_prompt_author_role_alternated(
        self, protocol_messages: Sequence[ProtocolMessage]
    ) -> Sequence[ProtocolMessage]:
        """

        Concatenate consecutive messages from the same author into a single message. This is useful
        for LLMs that require role alternation between user and bot messages.

        #### Parameters:
        - `protocol_messages` (`Sequence[ProtocolMessage]`): the messages to make alternated.
        #### Returns:
        - `Sequence[ProtocolMessage]`: the modified messages.

        """
        new_messages = []

        for protocol_message in protocol_messages:
            if new_messages and protocol_message.role == new_messages[-1].role:
                prev_message = new_messages.pop()
                new_content = prev_message.content + "\n\n" + protocol_message.content

                new_attachments = []
                added_attachment_urls = set()
                for attachment in (
                    protocol_message.attachments + prev_message.attachments
                ):
                    if attachment.url not in added_attachment_urls:
                        added_attachment_urls.add(attachment.url)
                        new_attachments.append(attachment)

                new_messages.append(
                    prev_message.model_copy(
                        update={"content": new_content, "attachments": new_attachments}
                    )
                )
            else:
                new_messages.append(protocol_message)

        return new_messages

    async def capture_cost(
        self,
        request: QueryRequest,
        amounts: Union[list[CostItem], CostItem],
        base_url: str = "https://api.poe.com/",
    ) -> None:
        """

        Used to capture variable costs for monetized and eligible bot creators.
        Visit https://creator.poe.com/docs/creator-monetization for more information.

        #### Parameters:
        - `request` (`QueryRequest`): The currently handled QueryRequest object.
        - `amounts` (`Union[list[CostItem], CostItem]`): The to be captured amounts.

        #### Returns: `None`

        """

        if not self.access_key:
            raise CostRequestError(
                "Please provide the bot access_key when make_app is called."
            )

        if not request.bot_query_id:
            raise InvalidParameterError(
                "bot_query_id is required to make cost requests."
            )

        url = f"{base_url}bot/cost/{request.bot_query_id}/capture"
        result = await self._cost_requests_inner(
            amounts=amounts, access_key=self.access_key, url=url
        )
        if not result:
            raise InsufficientFundError()

    async def authorize_cost(
        self,
        request: QueryRequest,
        amounts: Union[list[CostItem], CostItem],
        base_url: str = "https://api.poe.com/",
    ) -> None:
        """

        Used to authorize a cost for monetized and eligible bot creators.
        Visit https://creator.poe.com/docs/creator-monetization for more information.

        #### Parameters:
        - `request` (`QueryRequest`): The currently handled QueryRequest object.
        - `amounts` (`Union[list[CostItem], CostItem]`): The to be authorized amounts.

        #### Returns: `None`

        """

        if not self.access_key:
            raise CostRequestError(
                "Please provide the bot access_key when make_app is called."
            )

        if not request.bot_query_id:
            raise InvalidParameterError(
                "bot_query_id is required to make cost requests."
            )

        url = f"{base_url}bot/cost/{request.bot_query_id}/authorize"
        result = await self._cost_requests_inner(
            amounts=amounts, access_key=self.access_key, url=url
        )
        if not result:
            raise InsufficientFundError()

    async def _cost_requests_inner(
        self, amounts: Union[list[CostItem], CostItem], access_key: str, url: str
    ) -> bool:
        amounts = [amounts] if isinstance(amounts, CostItem) else amounts
        amounts_dicts = [amount.model_dump() for amount in amounts]
        data = {"amounts": amounts_dicts, "access_key": access_key}
        try:
            async with (
                httpx.AsyncClient(timeout=300) as client,
                httpx_sse.aconnect_sse(
                    client, method="POST", url=url, json=data
                ) as event_source,
            ):
                if event_source.response.status_code != 200:
                    error_pieces = [
                        json.loads(event.data).get("message", "")
                        async for event in event_source.aiter_sse()
                    ]
                    raise CostRequestError(
                        f"{event_source.response.status_code} "
                        f"{event_source.response.reason_phrase}: {''.join(error_pieces)}"
                    )

                async for event in event_source.aiter_sse():
                    if event.event == "result":
                        event_data = json.loads(event.data)
                        result = event_data["status"]
                        return result == "success"
            return False
        except httpx.HTTPError:
            logger.error(
                "An HTTP error occurred when attempting to send a cost request."
            )
            raise

    @staticmethod
    def text_event(text: str) -> ServerSentEvent:
        return ServerSentEvent(data=json.dumps({"text": text}), event="text")

    @staticmethod
    def file_event(
        url: str, content_type: str, name: str, inline_ref: Optional[str] = None
    ) -> ServerSentEvent:
        return ServerSentEvent(
            data=json.dumps(
                {
                    "url": url,
                    "content_type": content_type,
                    "name": name,
                    "inline_ref": inline_ref,
                }
            ),
            event="file",
        )

    @staticmethod
    def data_event(metadata: str) -> ServerSentEvent:
        return ServerSentEvent(data=json.dumps({"metadata": metadata}), event="data")

    @staticmethod
    def replace_response_event(text: str) -> ServerSentEvent:
        return ServerSentEvent(
            data=json.dumps({"text": text}), event="replace_response"
        )

    @staticmethod
    def done_event() -> ServerSentEvent:
        return ServerSentEvent(data="{}", event="done")

    @staticmethod
    def suggested_reply_event(text: str) -> ServerSentEvent:
        return ServerSentEvent(data=json.dumps({"text": text}), event="suggested_reply")

    @staticmethod
    def meta_event(
        *,
        content_type: ContentType = "text/markdown",
        refetch_settings: bool = False,
        linkify: bool = True,
        suggested_replies: bool = False,
    ) -> ServerSentEvent:
        return ServerSentEvent(
            data=json.dumps(
                {
                    "content_type": content_type,
                    "refetch_settings": refetch_settings,
                    "linkify": linkify,
                    "suggested_replies": suggested_replies,
                }
            ),
            event="meta",
        )

    @staticmethod
    def error_event(
        text: Optional[str] = None,
        *,
        raw_response: Optional[object] = None,
        allow_retry: bool = True,
        error_type: Optional[str] = None,
    ) -> ServerSentEvent:
        data: dict[str, Union[bool, str]] = {"allow_retry": allow_retry}
        if text is not None:
            data["text"] = text
        if raw_response is not None:
            data["raw_response"] = repr(raw_response)
        if error_type is not None:
            data["error_type"] = error_type
        return ServerSentEvent(data=json.dumps(data), event="error")

    # Internal handlers

    async def handle_report_feedback(
        self, feedback_request: ReportFeedbackRequest, context: RequestContext
    ) -> JSONResponse:
        await self.on_feedback_with_context(feedback_request, context)
        return JSONResponse({})

    async def handle_report_reaction(
        self, reaction_request: ReportReactionRequest, context: RequestContext
    ) -> JSONResponse:
        await self.on_reaction_with_context(reaction_request, context)
        return JSONResponse({})

    async def handle_report_error(
        self, error_request: ReportErrorRequest, context: RequestContext
    ) -> JSONResponse:
        await self.on_error_with_context(error_request, context)
        return JSONResponse({})

    async def handle_settings(
        self, settings_request: SettingsRequest, context: RequestContext
    ) -> JSONResponse:
        settings = await self.get_settings_with_context(settings_request, context)
        return JSONResponse(settings.dict())

    async def _yield_pending_file_events(
        self, message_id: Identifier
    ) -> AsyncIterable[ServerSentEvent]:
        file_events_to_yield = self._file_events_to_yield.pop(message_id, [])
        for fe in file_events_to_yield:
            yield fe

    async def handle_query(
        self, request: QueryRequest, context: RequestContext
    ) -> AsyncIterable[ServerSentEvent]:
        try:
            if self.should_insert_attachment_messages:
                request = self.insert_attachment_messages(query_request=request)
            elif self.concat_attachments_to_message:
                warnings.warn(
                    "concat_attachments_to_message is deprecated. "
                    "Use should_insert_attachment_messages instead.",
                    DeprecationWarning,
                    stacklevel=2,
                )
                request = self.concat_attachment_content_to_message_body(
                    query_request=request
                )
            async for event in self.get_response_with_context(request, context):
                # yield any pending file events from post_message_attachment first.
                # this is to ensure responses with inline_ref are sent after attachment is made.
                async for pending_file_event in self._yield_pending_file_events(
                    request.message_id
                ):
                    yield pending_file_event

                if isinstance(event, PartialResponse) and event.attachment:
                    attachment = event.attachment
                    yield self.file_event(
                        url=attachment.url,
                        content_type=attachment.content_type,
                        name=attachment.name,
                        inline_ref=attachment.inline_ref,
                    )

                if isinstance(event, ServerSentEvent):
                    yield event
                elif isinstance(event, ErrorResponse):
                    yield self.error_event(
                        event.text,
                        raw_response=event.raw_response,
                        allow_retry=event.allow_retry,
                        error_type=event.error_type,
                    )
                elif isinstance(event, MetaResponse):
                    yield self.meta_event(
                        content_type=event.content_type,
                        refetch_settings=event.refetch_settings,
                        linkify=event.linkify,
                        suggested_replies=event.suggested_replies,
                    )
                elif isinstance(event, DataResponse):
                    yield self.data_event(event.metadata)
                elif event.is_suggested_reply:
                    yield self.suggested_reply_event(event.text)
                elif event.is_replace_response:
                    yield self.replace_response_event(event.text)
                else:
                    yield self.text_event(event.text)

            # yield any remaining file events
            async for pending_file_event in self._yield_pending_file_events(
                request.message_id
            ):
                yield pending_file_event
        except Exception as e:
            logger.exception("Error responding to query")
            yield self.error_event(
                "The bot encountered an unexpected issue.",
                raw_response=e,
                allow_retry=False,
            )
        yield self.done_event()


def _find_access_key(*, access_key: str, api_key: str) -> Optional[str]:
    """Figures out the access key.

    The order of preference is:
    1) access_key=
    2) $POE_ACCESS_KEY
    3) api_key=
    4) $POE_API_KEY

    """
    if access_key:
        return access_key

    environ_poe_access_key = os.environ.get("POE_ACCESS_KEY")
    if environ_poe_access_key:
        return environ_poe_access_key

    if api_key:
        warnings.warn(
            "usage of api_key is deprecated, pass your key using access_key instead",
            DeprecationWarning,
            stacklevel=3,
        )
        return api_key

    environ_poe_api_key = os.environ.get("POE_API_KEY")
    if environ_poe_api_key:
        warnings.warn(
            "usage of POE_API_KEY is deprecated, pass your key using POE_ACCESS_KEY instead",
            DeprecationWarning,
            stacklevel=3,
        )
        return environ_poe_api_key

    return None


def _verify_access_key(
    *, access_key: str, api_key: str, allow_without_key: bool = False
) -> Optional[str]:
    """Checks whether we have a valid access key and returns it."""
    _access_key = _find_access_key(access_key=access_key, api_key=api_key)
    if not _access_key:
        if allow_without_key:
            return None
        print(
            "Please provide an access key.\n"
            "You can get a key from the create_bot page at: https://poe.com/create_bot?server=1\n"
            "You can then pass the key using the access_key param to the run() or make_app() "
            "functions, or by using the POE_ACCESS_KEY environment variable."
        )
        sys.exit(1)
    if len(_access_key) != 32:
        print("Invalid access key (should be 32 characters)")
        sys.exit(1)
    return _access_key


def _add_routes_for_bot(app: FastAPI, bot: PoeBot) -> None:
    async def index() -> Response:
        url = "https://poe.com/create_bot?server=1"
        return HTMLResponse(
            "<html><body><h1>FastAPI Poe bot server</h1><p>Congratulations! Your server"
            " is running. To connect it to Poe, create a bot at <a"
            f' href="{url}">{url}</a>.</p></body></html>'
        )

    def auth_user(
        authorization: HTTPAuthorizationCredentials = Depends(http_bearer),
    ) -> None:
        if bot.access_key is None:
            return
        if (
            authorization.scheme != "Bearer"
            or authorization.credentials != bot.access_key
        ):
            raise HTTPException(
                status_code=401,
                detail="Invalid access key",
                headers={"WWW-Authenticate": "Bearer"},
            )

    async def poe_post(request: Request, dict: object = Depends(auth_user)) -> Response:
        request_body = await request.json()
        request_body["http_request"] = request
        if request_body["type"] == "query":
            return EventSourceResponse(
                bot.handle_query(
                    QueryRequest.parse_obj(
                        {
                            **request_body,
                            "access_key": bot.access_key or "<missing>",
                            "api_key": bot.access_key or "<missing>",
                        }
                    ),
                    RequestContext(http_request=request),
                )
            )
        elif request_body["type"] == "settings":
            return await bot.handle_settings(
                SettingsRequest.parse_obj(request_body),
                RequestContext(http_request=request),
            )
        elif request_body["type"] == "report_feedback":
            return await bot.handle_report_feedback(
                ReportFeedbackRequest.parse_obj(request_body),
                RequestContext(http_request=request),
            )
        elif request_body["type"] == "report_reaction":
            return await bot.handle_report_reaction(
                ReportReactionRequest.parse_obj(request_body),
                RequestContext(http_request=request),
            )
        elif request_body["type"] == "report_error":
            return await bot.handle_report_error(
                ReportErrorRequest.parse_obj(request_body),
                RequestContext(http_request=request),
            )
        else:
            raise HTTPException(status_code=501, detail="Unsupported request type")

    app.get(bot.path)(index)
    app.post(bot.path)(poe_post)


def make_app(
    bot: Union[PoeBot, Sequence[PoeBot]],
    access_key: str = "",
    *,
    bot_name: str = "",
    api_key: str = "",
    allow_without_key: bool = False,
    app: Optional[FastAPI] = None,
) -> FastAPI:
    """

    Create an app object for your bot(s).

    #### Parameters:
    - `bot` (`Union[PoeBot, Sequence[PoeBot]]`): A bot object or a list of bot objects if you want
    to host multiple bots on one server.
    - `access_key` (`str = ""`): The access key to use.  If not provided, the server tries to
    read the POE_ACCESS_KEY environment variable. If that is not set, the server will
    refuse to start, unless `allow_without_key` is True. If multiple bots are provided,
    the access key must be provided as part of the bot object.
    - `bot_name` (`str = ""`): The name of the bot as it appears on poe.com.
    - `api_key` (`str = ""`): **DEPRECATED**: Please set the access_key when creating the Bot
    object instead.
    - `allow_without_key` (`bool = False`): If True, the server will start even if no access
    key is provided. Requests will not be checked against any key. If an access key is provided, it
    is still checked.
    - `app` (`Optional[FastAPI] = None`): A FastAPI app instance. If provided, the app will be
    configured with the provided bots, access keys, and other settings. If not provided, a new
    FastAPI application instance will be created and configured.
    #### Returns:
    - `FastAPI`: A FastAPI app configured to serve your bot when run.

    """
    if app is None:
        app = FastAPI()
    app.add_exception_handler(RequestValidationError, http_exception_handler)

    if isinstance(bot, PoeBot):
        if bot.access_key is None:
            bot.access_key = _verify_access_key(
                access_key=access_key,
                api_key=api_key,
                allow_without_key=allow_without_key,
            )
        elif access_key:
            raise ValueError(
                "Cannot provide access_key if the bot object already has an access key"
            )
        elif api_key:
            raise ValueError(
                "Cannot provide api_key if the bot object already has an access key"
            )

        if bot.bot_name is None:
            bot.bot_name = bot_name
        elif bot_name:
            raise ValueError(
                "Cannot provide bot_name if the bot object already has a bot_name"
            )
        bots = [bot]
    else:
        if access_key or api_key or bot_name:
            raise ValueError(
                "When serving multiple bots, the access_key/bot_name must be set on each bot"
            )
        bots = bot

    # Ensure paths are unique
    path_to_bots = defaultdict(list)
    for bot in bots:
        path_to_bots[bot.path].append(bot)
    for path, bots_of_path in path_to_bots.items():
        if len(bots_of_path) > 1:
            raise ValueError(
                f"Multiple bots are trying to use the same path: {path}: {bots_of_path}. "
                "Please use a different path for each bot."
            )

    for bot_obj in bots:
        if bot_obj.access_key is None and not allow_without_key:
            raise ValueError(f"Missing access key on {bot_obj}")
        _add_routes_for_bot(app, bot_obj)
        if not bot_obj.bot_name or not bot_obj.access_key:
            logger.warning("\n************* Warning *************")
            logger.warning(
                "Bot name or access key is not set for PoeBot.\n"
                "Bot settings will NOT be synced automatically on server start/update."
                "Please remember to sync bot settings manually.\n\n"
                "For more information, see: https://creator.poe.com/docs/server-bots/updating-bot-settings"
            )
            logger.warning("\n************* Warning *************")
        else:
            try:
                settings_response = asyncio.run(
                    bot_obj.get_settings(
                        SettingsRequest(version=PROTOCOL_VERSION, type="settings")
                    )
                )
                sync_bot_settings(
                    bot_name=bot_obj.bot_name,
                    settings=settings_response.model_dump(),
                    access_key=bot_obj.access_key,
                )
            except Exception as e:
                logger.error("\n*********** Error ***********")
                logger.error(
                    f"Bot settings sync failed for {bot_obj.bot_name}: \n{e}\n\n"
                )
                logger.error("Please sync bot settings manually.\n\n")
                logger.error(
                    "For more information, see: https://creator.poe.com/docs/server-bots/updating-bot-settings"
                )
                logger.error("\n*********** Error ***********")

    # Uncomment this line to print out request and response
    # app.add_middleware(LoggingMiddleware)
    return app


def run(
    bot: Union[PoeBot, Sequence[PoeBot]],
    access_key: str = "",
    *,
    api_key: str = "",
    allow_without_key: bool = False,
    app: Optional[FastAPI] = None,
) -> None:
    """

    Serve a poe bot using a FastAPI app. This function should be used when you are running the
    bot locally. The parameters are the same as they are for `make_app`.

    #### Returns: `None`

    """

    app = make_app(
        bot,
        access_key=access_key,
        api_key=api_key,
        allow_without_key=allow_without_key,
        app=app,
    )

    parser = argparse.ArgumentParser("FastAPI sample Poe bot server")
    parser.add_argument("-p", "--port", type=int, default=8080)
    args = parser.parse_args()
    port = args.port

    logger.info("Starting")
    import uvicorn.config

    log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
    log_config["formatters"]["default"][
        "fmt"
    ] = "%(asctime)s - %(levelname)s - %(message)s"
    uvicorn.run(app, host="0.0.0.0", port=port, log_config=log_config)


================================================
FILE: src/fastapi_poe/client.py
================================================
"""

Client for talking to other Poe bots through the Poe bot query API.
For more details, see: https://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe

"""

import asyncio
import contextlib
import inspect
import io
import json
import os
import warnings
from collections.abc import AsyncGenerator, Generator
from dataclasses import dataclass, field
from typing import Any, BinaryIO, Callable, Optional, Union, cast

import httpx
import httpx_sse

from fastapi_poe.sync_utils import run_sync

from .types import (
    Attachment,
    ContentType,
    FunctionCallDefinition,
    Identifier,
    ProtocolMessage,
    QueryRequest,
    SettingsResponse,
    ToolCallDefinition,
    ToolCallDefinitionDelta,
    ToolDefinition,
    ToolResultDefinition,
)
from .types import MetaResponse as MetaMessage
from .types import PartialResponse as BotMessage

PROTOCOL_VERSION = "1.2"
MESSAGE_LENGTH_LIMIT = 10_000

IDENTIFIER_LENGTH = 32
MAX_EVENT_COUNT = 1000

ErrorHandler = Callable[[Exception, str], None]


class AttachmentUploadError(Exception):
    """Raised when there is an error uploading an attachment."""


class BotError(Exception):
    """Raised when there is an error communicating with the bot."""


class BotErrorNoRetry(BotError):
    """Subclass of BotError raised when we're not allowed to retry."""


class InvalidBotSettings(Exception):
    """Raised when a bot returns invalid settings."""


def _safe_ellipsis(obj: object, limit: int) -> str:
    if not isinstance(obj, str):
        obj = repr(obj)
    if len(obj) > limit:
        obj = obj[: limit - 3] + "..."
    return obj


@dataclass
class _BotContext:
    endpoint: str
    session: httpx.AsyncClient = field(repr=False)
    api_key: Optional[str] = field(default=None, repr=False)
    on_error: Optional[ErrorHandler] = field(default=None, repr=False)
    extra_headers: Optional[dict[str, str]] = field(default=None, repr=False)

    @property
    def headers(self) -> dict[str, str]:
        headers = {"Accept": "application/json"}
        if self.api_key is not None:
            headers["Authorization"] = f"Bearer {self.api_key}"
        if self.extra_headers is not None:
            headers.update(self.extra_headers)
        return headers

    async def report_error(
        self, message: str, metadata: Optional[dict[str, Any]] = None
    ) -> None:
        """Report an error to the bot server."""
        if self.on_error is not None:
            long_message = (
                f"Protocol bot error: {message} with metadata {metadata} "
                f"for endpoint {self.endpoint}"
            )
            self.on_error(BotError(message), long_message)
        await self.session.post(
            self.endpoint,
            headers=self.headers,
            json={
                "version": PROTOCOL_VERSION,
                "type": "report_error",
                "message": message,
                "metadata": metadata or {},
            },
        )

    async def report_feedback(
        self,
        message_id: Identifier,
        user_id: Identifier,
        conversation_id: Identifier,
        feedback_type: str,
    ) -> None:
        """Report message feedback to the bot server."""
        await self.session.post(
            self.endpoint,
            headers=self.headers,
            json={
                "version": PROTOCOL_VERSION,
                "type": "report_feedback",
                "message_id": message_id,
                "user_id": user_id,
                "conversation_id": conversation_id,
                "feedback_type": feedback_type,
            },
        )

    async def report_reaction(
        self,
        message_id: Identifier,
        user_id: Identifier,
        conversation_id: Identifier,
        reaction: str,
    ) -> None:
        """Report message reaction to the bot server."""
        await self.session.post(
            self.endpoint,
            headers=self.headers,
            json={
                "version": PROTOCOL_VERSION,
                "type": "report_reaction",
                "message_id": message_id,
                "user_id": user_id,
                "conversation_id": conversation_id,
                "reaction": reaction,
            },
        )

    async def fetch_settings(self) -> SettingsResponse:
        """Fetches settings from a Poe server bot endpoint."""
        resp = await self.session.post(
            self.endpoint,
            headers=self.headers,
            json={"version": PROTOCOL_VERSION, "type": "settings"},
        )
        return resp.json()

    async def perform_query_request(
        self,
        *,
        request: QueryRequest,
        tools: Optional[list[ToolDefinition]],
        tool_calls: Optional[list[ToolCallDefinition]],
        tool_results: Optional[list[ToolResultDefinition]],
    ) -> AsyncGenerator[BotMessage, None]:
        chunks: list[str] = []
        message_id = request.message_id
        event_count = 0
        error_reported = False
        payload = request.model_dump()
        if tools is not None:
            payload["tools"] = [tool.model_dump() for tool in tools]
        if tool_calls is not None:
            payload["tool_calls"] = [tool_call.model_dump() for tool_call in tool_calls]
        if tool_results is not None:
            payload["tool_results"] = [
                tool_result.model_dump() for tool_result in tool_results
            ]
        async with httpx_sse.aconnect_sse(
            self.session, "POST", self.endpoint, headers=self.headers, json=payload
        ) as event_source:
            async for event in event_source.aiter_sse():
                event_count += 1
                index: Optional[int] = await self._get_single_json_integer_field_safe(
                    event.data, event.event, message_id, "index"
                )
                if event.event == "done":
                    # Don't send a report if we already told the bot about some other mistake.
                    if not chunks and not error_reported and not tools:
                        await self.report_error(
                            "Bot returned no text in response",
                            {"message_id": message_id},
                        )
                    return
                elif event.event == "text":
                    text = await self._get_single_json_field(
                        event.data, "text", message_id
                    )
                elif event.event == "replace_response":
                    text = await self._get_single_json_field(
                        event.data, "replace_response", message_id
                    )
                    chunks.clear()
                elif event.event == "file":
                    yield BotMessage(
                        text="",
                        attachment=Attachment(
                            url=await self._get_single_json_field(
                                event.data, "file", message_id, "url"
                            ),
                            content_type=await self._get_single_json_field(
                                event.data, "file", message_id, "content_type"
                            ),
                            name=await self._get_single_json_field(
                                event.data, "file", message_id, "name"
                            ),
                            inline_ref=await self._get_single_json_string_field_safe(
                                event.data, "file", message_id, "inline_ref"
                            ),
                        ),
                        index=index,
                    )
                    continue
                elif event.event == "suggested_reply":
                    text = await self._get_single_json_field(
                        event.data, "suggested_reply", message_id
                    )
                    yield BotMessage(
                        text=text,
                        raw_response={"type": event.event, "text": event.data},
                        full_prompt=repr(request),
                        is_suggested_reply=True,
                        index=index,
                    )
                    continue
                elif event.event == "json":
                    yield BotMessage(
                        text="",
                        data=json.loads(event.data),
                        full_prompt=repr(request),
                        index=index,
                    )
                    continue
                elif event.event == "meta":
                    if event_count != 1:
                        # spec says a meta event that is not the first event is ignored
                        continue
                    data = await self._load_json_dict(event.data, "meta", message_id)
                    linkify = data.get("linkify", False)
                    if not isinstance(linkify, bool):
                        await self.report_error(
                            "Invalid linkify value in 'meta' event",
                            {"message_id": message_id, "linkify": linkify},
                        )
                        error_reported = True
                        continue
                    send_suggested_replies = data.get("suggested_replies", False)
                    if not isinstance(send_suggested_replies, bool):
                        await self.report_error(
                            "Invalid suggested_replies value in 'meta' event",
                            {
                                "message_id": message_id,
                                "suggested_replies": send_suggested_replies,
                            },
                        )
                        error_reported = True
                        continue
                    content_type = data.get("content_type", "text/markdown")
                    if not isinstance(content_type, str):
                        await self.report_error(
                            "Invalid content_type value in 'meta' event",
                            {"message_id": message_id, "content_type": content_type},
                        )
                        error_reported = True
                        continue
                    yield MetaMessage(
                        text="",
                        raw_response=data,
                        full_prompt=repr(request),
                        linkify=linkify,
                        suggested_replies=send_suggested_replies,
                        content_type=cast(ContentType, content_type),
                    )
                    continue
                elif event.event == "error":
                    data = await self._load_json_dict(event.data, "error", message_id)
                    if data.get("allow_retry", True):
                        raise BotError(event.data)
                    else:
                        raise BotErrorNoRetry(event.data)
                elif event.event == "ping":
                    # Not formally part of the spec, but FastAPI sends this; let's ignore it
                    # instead of sending error reports.
                    continue
                else:
                    # Truncate the type and message in case it's huge.
                    await self.report_error(
                        f"Unknown event type: {_safe_ellipsis(event.event, 100)}",
                        {
                            "event_data": _safe_ellipsis(event.data, 500),
                            "message_id": message_id,
                        },
                    )
                    error_reported = True
                    continue
                chunks.append(text)
                yield BotMessage(
                    text=text,
                    raw_response={"type": event.event, "text": event.data},
                    full_prompt=repr(request),
                    is_replace_response=(event.event == "replace_response"),
                    index=index,
                )
        await self.report_error(
            "Bot exited without sending 'done' event", {"message_id": message_id}
        )

    async def _get_single_json_field(
        self, data: str, context: str, message_id: Identifier, field: str = "text"
    ) -> str:
        data_dict = await self._load_json_dict(data, context, message_id)
        text = data_dict[field]
        if not isinstance(text, str):
            await self.report_error(
                f"Expected string in '{field}' field for '{context}' event",
                {"data": data_dict, "message_id": message_id},
            )
            raise BotErrorNoRetry(f"Expected string in '{context}' event")
        return text

    async def _get_single_json_string_field_safe(
        self, data: str, context: str, message_id: Identifier, field: str
    ) -> Optional[str]:
        data_dict = await self._load_json_dict(data, context, message_id)
        if field not in data_dict:
            return None
        result = data_dict[field]
        if not isinstance(result, str):
            return None
        return result

    async def _get_single_json_integer_field_safe(
        self, data: str, context: str, message_id: Identifier, field: str
    ) -> Optional[int]:
        data_dict = await self._load_json_dict(data, context, message_id)
        if field not in data_dict:
            return None
        result = data_dict[field]
        if not isinstance(result, int):
            return None
        return result

    async def _load_json_dict(
        self, data: str, context: str, message_id: Identifier
    ) -> dict[str, object]:
        try:
            parsed = json.loads(data)
        except json.JSONDecodeError:
            await self.report_error(
                f"Invalid JSON in {context!r} event",
                {"data": data, "message_id": message_id},
            )
            # If they are returning invalid JSON, retrying immediately probably won't help
            raise BotErrorNoRetry(f"Invalid JSON in {context!r} event") from None
        if not isinstance(parsed, dict):
            await self.report_error(
                f"Expected JSON dict in {context!r} event",
                {"data": data, "message_id": message_id},
            )
            raise BotError(f"Expected JSON dict in {context!r} event")
        return cast(dict[str, object], parsed)


def _default_error_handler(e: Exception, msg: str) -> None:
    print("Error in Poe bot:", msg, "\n", repr(e))


async def stream_request(
    request: QueryRequest,
    bot_name: str,
    api_key: str = "",
    *,
    tools: Optional[list[ToolDefinition]] = None,
    tool_executables: Optional[list[Callable]] = None,
    access_key: str = "",
    access_key_deprecation_warning_stacklevel: int = 2,
    session: Optional[httpx.AsyncClient] = None,
    on_error: ErrorHandler = _default_error_handler,
    num_tries: int = 2,
    retry_sleep_time: float = 0.5,
    base_url: str = "https://api.poe.com/bot/",
    extra_headers: Optional[dict[str, str]] = None,
) -> AsyncGenerator[BotMessage, None]:
    """

    The Entry point for the Bot Query API. This API allows you to use other bots on Poe for
    inference in response to a user message. For more details, checkout:
    https://creator.poe.com/docs/server-bots-functional-guides#accessing-other-bots-on-poe

    #### Parameters:
    - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object
    also includes information needed to identify the user for compute point usage.
    - `bot_name` (`str`): The bot you want to invoke.
    - `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. You will need
    this in case you are trying to use this function from a script/shell. Note that if an `api_key`
    is provided, compute points will be charged on the account corresponding to the `api_key`.
    - tools: (`Optional[list[ToolDefinition]] = None`): A list of ToolDefinition objects describing
    the functions you have. This is used for OpenAI function calling.
    - tool_executables: (`Optional[list[Callable]] = None`): A list of functions corresponding
    to the ToolDefinitions. This is used for OpenAI function calling. When this is set, the
    LLM-suggested tools will automatically run once, before passing the results back to the LLM for
    a final response.

    """
    if tools is not None:
        async for message in _stream_request_with_tools(
            request=request,
            bot_name=bot_name,
            api_key=api_key,
            tools=tools,
            tool_executables=tool_executables,
            access_key=access_key,
            access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,
            session=session,
            on_error=on_error,
            num_tries=num_tries,
            retry_sleep_time=retry_sleep_time,
            base_url=base_url,
            extra_headers=extra_headers,
        ):
            yield message

    else:
        async for message in stream_request_base(
            request=request,
            bot_name=bot_name,
            api_key=api_key,
            access_key=access_key,
            access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,
            session=session,
            on_error=on_error,
            num_tries=num_tries,
            retry_sleep_time=retry_sleep_time,
            base_url=base_url,
            extra_headers=extra_headers,
        ):
            yield message


async def _get_tool_results(
    tool_executables: list[Callable], tool_calls: list[ToolCallDefinition]
) -> list[ToolResultDefinition]:
    tool_executables_dict = {
        executable.__name__: executable for executable in tool_executables
    }
    tool_results = []
    for tool_call in tool_calls:
        tool_call_id = tool_call.id
        name = tool_call.function.name
        arguments = json.loads(tool_call.function.arguments)
        _func = tool_executables_dict[name]
        if inspect.iscoroutinefunction(_func):
            content = await _func(**arguments)
        else:
            content = _func(**arguments)
        tool_results.append(
            ToolResultDefinition(
                role="tool",
                tool_call_id=tool_call_id,
                name=name,
                content=json.dumps(content),
            )
        )
    return tool_results


async def _stream_request_with_tools(
    request: QueryRequest,
    bot_name: str,
    api_key: str = "",
    *,
    tools: list[ToolDefinition],
    tool_executables: Optional[list[Callable]] = None,
    access_key: str = "",
    access_key_deprecation_warning_stacklevel: int = 2,
    session: Optional[httpx.AsyncClient] = None,
    on_error: ErrorHandler = _default_error_handler,
    num_tries: int = 2,
    retry_sleep_time: float = 0.5,
    base_url: str = "https://api.poe.com/bot/",
    extra_headers: Optional[dict[str, str]] = None,
) -> AsyncGenerator[BotMessage, None]:
    aggregated_tool_calls: dict[int, ToolCallDefinition] = {}
    async for message in stream_request_base(
        request=request,
        bot_name=bot_name,
        api_key=api_key,
        tools=tools,
        access_key=access_key,
        access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,
        session=session,
        on_error=on_error,
        num_tries=num_tries,
        retry_sleep_time=retry_sleep_time,
        base_url=base_url,
        extra_headers=extra_headers,
    ):
        if (
            message.data is None
            or "choices" not in message.data
            or not message.data["choices"]
        ):
            yield message
            continue

        # If there is a finish reason, skip the chunk. This should be the same as breaking out of
        # the loop for most models, but we continue to cover situations where other kinds of
        # chunks might stream in after the finish chunk.
        finish_reason = message.data["choices"][0]["finish_reason"]
        if finish_reason is not None:
            continue

        if "tool_calls" in message.data["choices"][0]["delta"]:
            tool_call_deltas: list[ToolCallDefinitionDelta] = [
                ToolCallDefinitionDelta(**tool_call_object)
                for tool_call_object in message.data["choices"][0]["delta"][
                    "tool_calls"
                ]
            ]
            # If tool_executables is not set, return the tool calls without executing them,
            # allowing the caller to manage the tool call loop.
            if tool_executables is None:
                yield BotMessage(
                    text="", tool_calls=tool_call_deltas, index=message.index
                )
                continue

            for tool_call_delta in tool_call_deltas:
                if tool_call_delta.index not in aggregated_tool_calls:
                    # The first chunk of a given index must contain id, type, and function.name.
                    # If this first chunk is missing, the tool call for that index cannot be
                    # aggregated.
                    if (
                        tool_call_delta.id is None
                        or tool_call_delta.type is None
                        or tool_call_delta.function.name is None
                    ):
                        continue

                    aggregated_tool_calls[tool_call_delta.index] = ToolCallDefinition(
                        id=tool_call_delta.id,
                        type=tool_call_delta.type,
                        function=FunctionCallDefinition(
                            name=tool_call_delta.function.name,
                            arguments=tool_call_delta.function.arguments,
                        ),
                    )
                else:
                    aggregated_tool_calls[
                        tool_call_delta.index
                    ].function.arguments += tool_call_delta.function.arguments

        # if no tool calls are selected, the deltas contain content instead of tool_calls
        elif "content" in message.data["choices"][0]["delta"]:
            yield BotMessage(
                text=message.data["choices"][0]["delta"]["content"], index=message.index
            )

    # If tool_executables is not set, exit early since there are no functions to execute.
    if not tool_executables:
        return

    tool_calls: list[ToolCallDefinition] = list(aggregated_tool_calls.values())
    tool_results = await _get_tool_results(tool_executables, tool_calls)

    # If we have tool calls and tool results, we still need to get the final response from the
    # LLM.
    if tool_calls and tool_results:
        async for message in stream_request_base(
            request=request,
            bot_name=bot_name,
            api_key=api_key,
            tools=tools,
            tool_calls=tool_calls,
            tool_results=tool_results,
            access_key=access_key,
            access_key_deprecation_warning_stacklevel=access_key_deprecation_warning_stacklevel,
            session=session,
            on_error=on_error,
            num_tries=num_tries,
            retry_sleep_time=retry_sleep_time,
            base_url=base_url,
            extra_headers=extra_headers,
        ):
            yield message


async def stream_request_base(
    request: QueryRequest,
    bot_name: str,
    api_key: str = "",
    *,
    tools: Optional[list[ToolDefinition]] = None,
    tool_calls: Optional[list[ToolCallDefinition]] = None,
    tool_results: Optional[list[ToolResultDefinition]] = None,
    access_key: str = "",
    access_key_deprecation_warning_stacklevel: int = 2,
    session: Optional[httpx.AsyncClient] = None,
    on_error: ErrorHandler = _default_error_handler,
    num_tries: int = 2,
    retry_sleep_time: float = 0.5,
    base_url: str = "https://api.poe.com/bot/",
    extra_headers: Optional[dict[str, str]] = None,
) -> AsyncGenerator[BotMessage, None]:
    if access_key != "":
        warnings.warn(
            "the access_key param is no longer necessary when using this function.",
            DeprecationWarning,
            stacklevel=access_key_deprecation_warning_stacklevel,
        )

    async with contextlib.AsyncExitStack() as stack:
        if session is None:
            session = await stack.enter_async_context(httpx.AsyncClient(timeout=600))
        url = f"{base_url}{bot_name}"
        ctx = _BotContext(
            endpoint=url,
            api_key=api_key,
            session=session,
            on_error=on_error,
            extra_headers=extra_headers,
        )
        got_response = False
        for i in range(num_tries):
            try:
                async for message in ctx.perform_query_request(
                    request=request,
                    tools=tools,
                    tool_calls=tool_calls,
                    tool_results=tool_results,
                ):
                    got_response = True
                    yield message
                break
            except BotErrorNoRetry:
                raise
            except Exception as e:
                on_error(e, f"Bot request to {bot_name} failed on try {i}")
                # Want to retry on some errors even if we have streamed part of the request
                # RemoteProtocolError: peer closed connection without sending complete message body
                allow_retry_after_response = isinstance(e, httpx.RemoteProtocolError)
                if (
                    got_response and not allow_retry_after_response
                ) or i == num_tries - 1:
                    # If it's a BotError, it probably has a good error message
                    # that we want to show directly.
                    if isinstance(e, BotError):
                        raise
                    # But if it's something else (maybe an HTTP error or something),
                    # wrap it in a BotError that makes it clear which bot is broken.
                    raise BotError(f"Error communicating with bot {bot_name}") from e
                await asyncio.sleep(retry_sleep_time)


def get_bot_response(
    messages: list[ProtocolMessage],
    bot_name: str,
    api_key: str,
    *,
    tools: Optional[list[ToolDefinition]] = None,
    tool_executables: Optional[list[Callable]] = None,
    temperature: Optional[float] = None,
    skip_system_prompt: Optional[bool] = None,
    adopt_current_bot_name: Optional[bool] = None,
    logit_bias: Optional[dict[str, float]] = None,
    stop_sequences: Optional[list[str]] = None,
    base_url: str = "https://api.poe.com/bot/",
    session: Optional[httpx.AsyncClient] = None,
) -> AsyncGenerator[BotMessage, None]:
    """

    Use this function to invoke another Poe bot from your shell.

    #### Parameters:
    - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.
    - `bot_name` (`str`): The bot that you want to invoke.
    - `api_key` (`str`): Your Poe API key. Available at [poe.com/api_key](https://poe.com/api_key)
    - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects
    describing the functions you have. This is used for OpenAI function calling.
    - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding
    to the ToolDefinitions. This is used for OpenAI function calling.
    - `temperature` (`Optional[float] = None`): The temperature to use for the bot.
    - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.
    - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.
    - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.
    - `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is
    mainly for internal testing and is not expected to be changed.
    - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt
    the identity of the calling bot
    - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.
    """
    additional_params = {}
    # This is so that we don't have to redefine the default values for these params.
    if temperature is not None:
        additional_params["temperature"] = temperature
    if skip_system_prompt is not None:
        additional_params["skip_system_prompt"] = skip_system_prompt
    if logit_bias is not None:
        additional_params["logit_bias"] = logit_bias
    if stop_sequences is not None:
        additional_params["stop_sequences"] = stop_sequences
    if adopt_current_bot_name is not None:
        additional_params["adopt_current_bot_name"] = adopt_current_bot_name

    query = QueryRequest(
        query=messages,
        user_id="",
        conversation_id="",
        message_id="",
        version=PROTOCOL_VERSION,
        type="query",
        **additional_params,
    )
    return stream_request(
        request=query,
        bot_name=bot_name,
        api_key=api_key,
        tools=tools,
        tool_executables=tool_executables,
        base_url=base_url,
        session=session,
    )


def get_bot_response_sync(
    messages: list[ProtocolMessage],
    bot_name: str,
    api_key: str,
    *,
    tools: Optional[list[ToolDefinition]] = None,
    tool_executables: Optional[list[Callable]] = None,
    temperature: Optional[float] = None,
    skip_system_prompt: Optional[bool] = None,
    logit_bias: Optional[dict[str, float]] = None,
    adopt_current_bot_name: Optional[bool] = None,
    stop_sequences: Optional[list[str]] = None,
    base_url: str = "https://api.poe.com/bot/",
    session: Optional[httpx.AsyncClient] = None,
) -> Generator[BotMessage, None, None]:
    """

    This function wraps the async generator `fp.get_bot_response` and returns
    partial responses synchronously.

    For asynchronous streaming, or integration into an existing event loop, use
    `fp.get_bot_response` directly.

    #### Parameters:
    - `messages` (`list[ProtocolMessage]`): A list of messages representing your conversation.
    - `bot_name` (`str`): The bot that you want to invoke.
    - `api_key` (`str`): Your Poe API key. This is available at: [poe.com/api_key](https://poe.com/api_key)
    - `tools` (`Optional[list[ToolDefinition]] = None`): An list of ToolDefinition objects
    describing the functions you have. This is used for OpenAI function calling.
    - `tool_executables` (`Optional[list[Callable]] = None`): An list of functions corresponding
    to the ToolDefinitions. This is used for OpenAI function calling.
    - `temperature` (`Optional[float] = None`): The temperature to use for the bot.
    - `skip_system_prompt` (`Optional[bool] = None`): Whether to skip the system prompt.
    - `logit_bias` (`Optional[dict[str, float]] = None`): The logit bias to use for the bot.
    - `stop_sequences` (`Optional[list[str]] = None`): The stop sequences to use for the bot.
    - `base_url` (`str = "https://api.poe.com/bot/"`): The base URL to use for the bot. This is
    mainly for internal testing and is not expected to be changed.
    - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt
    the identity of the calling bot
    - `session` (`Optional[httpx.AsyncClient] = None`): The session to use for the bot.

    """

    async def _async_generator() -> AsyncGenerator[BotMessage, None]:
        async for partial in get_bot_response(
            messages=messages,
            bot_name=bot_name,
            api_key=api_key,
            tools=tools,
            tool_executables=tool_executables,
            temperature=temperature,
            skip_system_prompt=skip_system_prompt,
            adopt_current_bot_name=adopt_current_bot_name,
            logit_bias=logit_bias,
            stop_sequences=stop_sequences,
            base_url=base_url,
            session=session,
        ):
            yield partial

    def _sync_generator() -> Generator[BotMessage, None, None]:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)

        async_gen = _async_generator().__aiter__()
        try:
            while True:
                # Pull one item from the async generator at a time,
                # blocking until it’s ready.
                yield loop.run_until_complete(async_gen.__anext__())

        except StopAsyncIteration:
            pass

        finally:
            loop.run_until_complete(loop.shutdown_asyncgens())
            loop.close()

    return _sync_generator()


async def get_final_response(
    request: QueryRequest,
    bot_name: str,
    api_key: str = "",
    *,
    access_key: str = "",
    session: Optional[httpx.AsyncClient] = None,
    on_error: ErrorHandler = _default_error_handler,
    num_tries: int = 2,
    retry_sleep_time: float = 0.5,
    base_url: str = "https://api.poe.com/bot/",
) -> str:
    """

    A helper function for the bot query API that waits for all the tokens and concatenates the full
    response before returning.

    #### Parameters:
    - `request` (`QueryRequest`): A QueryRequest object representing a query from Poe. This object
    also includes information needed to identify the user for compute point usage.
    - `bot_name` (`str`): The bot you want to invoke.
    - `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. You will need this in
    case you are trying to use this function from a script/shell. Note that if an `api_key` is
    provided, compute points will be charged on the account corresponding to the `api_key`.

    """
    chunks: list[str] = []
    async for message in stream_request(
        request,
        bot_name,
        api_key,
        access_key=access_key,
        access_key_deprecation_warning_stacklevel=3,
        session=session,
        on_error=on_error,
        num_tries=num_tries,
        retry_sleep_time=retry_sleep_time,
        base_url=base_url,
    ):
        if isinstance(message, MetaMessage):
            continue
        if message.is_suggested_reply:
            continue
        if message.is_replace_response:
            chunks.clear()
        chunks.append(message.text)
    if not chunks:
        raise BotError(f"Bot {bot_name} sent no response")
    return "".join(chunks)


def sync_bot_settings(
    bot_name: str,
    access_key: str = "",
    *,
    settings: Optional[dict[str, Any]] = None,
    base_url: str = "https://api.poe.com/bot/",
) -> None:
    """Fetch settings from the running bot server, and then sync them with Poe."""
    try:
        if settings is None:
            response = httpx.post(
                f"{base_url}fetch_settings/{bot_name}/{access_key}/{PROTOCOL_VERSION}"
            )
        else:
            headers = {"Content-Type": "application/json"}
            response = httpx.post(
                f"{base_url}update_settings/{bot_name}/{access_key}/{PROTOCOL_VERSION}",
                headers=headers,
                json=settings,
            )
        if response.status_code != 200:
            raise BotError(
                f"Error syncing settings for bot {bot_name}: {response.text}"
            )
    except httpx.ReadTimeout as e:
        error_message = f"Timeout syncing settings for bot {bot_name}."
        if not settings:
            error_message += " Check that the bot server is running."
        raise BotError(error_message) from e
    print(response.text)


async def upload_file(
    file: Optional[Union[bytes, BinaryIO]] = None,
    file_url: Optional[str] = None,
    file_name: Optional[str] = None,
    api_key: str = "",
    *,
    session: Optional[httpx.AsyncClient] = None,
    on_error: ErrorHandler = _default_error_handler,
    num_tries: int = 2,
    retry_sleep_time: float = 0.5,
    base_url: str = "https://www.quora.com/poe_api/",
    extra_headers: Optional[dict[str, str]] = None,
) -> Attachment:
    """
    Upload a file (raw bytes *or* via URL) to Poe and receive an Attachment
    object that can be returned directly from a bot or stored for later use.

    #### Parameters:
    - `file` (`Optional[Union[bytes, BinaryIO]] = None`): The file to upload.
    - `file_url` (`Optional[str] = None`): The URL of the file to upload.
    - `file_name` (`Optional[str] = None`): The name of the file to upload. Required if
    `file` is provided as raw bytes.
    - `api_key` (`str = ""`): Your Poe API key, available at poe.com/api_key. This can
    also be the `access_key` if called from a Poe server bot.

    #### Returns:
    - `Attachment`: An Attachment object representing the uploaded file.

    """
    if not api_key:
        raise ValueError(
            "`api_key` is required (generate one at https://poe.com/api_key)"
        )
    if (file is None and file_url is None) or (file and file_url):
        raise ValueError("Provide either `file` or `file_url`, not both.")

    if file is not None and not file_name:
        if isinstance(file, io.IOBase):
            potential = getattr(file, "name", "")
            if potential:
                file_name = os.path.basename(potential)
            if not file_name:
                raise ValueError(
                    "`file_name` is mandatory when file object has no name attribute."
                )
        elif isinstance(file, (bytes, bytearray)):
            raise ValueError("`file_name` is mandatory when sending raw bytes.")
        else:
            raise ValueError("unsupported file type")

    endpoint = base_url.rstrip("/") + "/file_upload_3RD_PARTY_POST"

    async def _do_upload(_session: httpx.AsyncClient) -> Attachment:
        headers = {"Authorization": api_key}
        if extra_headers is not None:
            headers.update(extra_headers)

        if file_url:
            data: dict[str, str] = {"download_url": file_url}
            if file_name:
                data["download_filename"] = file_name
            request = _session.build_request(
                "POST", endpoint, data=data, headers=headers
            )
        else:  # raw bytes / BinaryIO
            assert (
                file is not None
            ), "file is required if file_url is not provided"  # pyright
            file_data = (
                file.read() if not isinstance(file, (bytes, bytearray)) else file
            )
            files = {"file": (file_name, file_data)}
            request = _session.build_request(
                "POST", endpoint, files=files, headers=headers
            )

        response = await _session.send(request)

        if response.status_code != 200:
            # collect full error text (endpoint streams errors)
            try:
                err_txt = await response.aread()
            except Exception:
                err_txt = response.text
            raise AttachmentUploadError(
                f"{response.status_code} {response.reason_phrase}: {err_txt}"
            )

        data = response.json()
        if not {"attachment_url", "mime_type"}.issubset(data):
            raise AttachmentUploadError(f"Unexpected response format: {data}")

        return Attachment(
            url=data["attachment_url"],
            content_type=data["mime_type"],
            name=file_name or "file",
        )

    # retry wrapper
    _sess = session or httpx.AsyncClient(timeout=120)
    async with _sess:
        for attempt in range(num_tries):
            try:
                return await _do_upload(_sess)
            except Exception as e:
                on_error(e, f"upload attempt {attempt+1}/{num_tries} failed")
                if attempt == num_tries - 1:
                    raise
                await asyncio.sleep(retry_sleep_time)

    raise AssertionError("retries exhausted")  # unreachable, but satisfies pyright


def upload_file_sync(
    file: Optional[Union[bytes, BinaryIO]] = None,
    file_url: Optional[str] = None,
    file_name: Optional[str] = None,
    api_key: str = "",
    *,
    session: Optional[httpx.AsyncClient] = None,
    on_error: ErrorHandler = _default_error_handler,
    num_tries: int = 2,
    retry_sleep_time: float = 0.5,
    base_url: str = "https://www.quora.com/poe_api/",
    extra_headers: Optional[dict[str, str]] = None,
) -> Attachment:
    """
    This is a synchronous wrapper around the async `upload_file`.

    """
    coro = upload_file(
        file=file,
        file_url=file_url,
        file_name=file_name,
        api_key=api_key,
        session=session,
        on_error=on_error,
        num_tries=num_tries,
        retry_sleep_time=retry_sleep_time,
        base_url=base_url,
        extra_headers=extra_headers,
    )
    return run_sync(coro, session=session)


================================================
FILE: src/fastapi_poe/py.typed
================================================


================================================
FILE: src/fastapi_poe/sync_utils.py
================================================
"""
Utility helpers for running async functions from synchronous code.

1. If there is no running event loop, just `asyncio.run`.
2. If there is a running loop, spin up a background thread that has
   its own loop and execute the coroutine there.
3. If the caller passes in an `httpx.AsyncClient` (or any external
   resource bound to the outer loop) while a loop is already running,
   raise because that resource cannot be reused safely in the thread.
"""

from __future__ import annotations

import asyncio
import concurrent.futures
from collections.abc import Coroutine
from typing import Any, Callable, TypeVar

T = TypeVar("T")


def run_sync(
    coro: Coroutine[Any, Any, T],
    *,
    session: object | None = None,
    _executor_factory: Callable[[], concurrent.futures.Executor] | None = None,
) -> T:
    """
    Run *coro* synchronously and return its result.

    Parameters
    ----------
    coro:
        Any awaitable (usually an async function call).
    session:
        Optional resource tied to the outer loop; if supplied while
        already inside a loop we refuse to continue.
    _executor_factory:
        *Test seam* - lets tests inject a dummy executor.

    Raises
    ------
    ValueError
        If called from inside an event loop *and* ``session`` is passed in.
    """
    # ──────────────────────────────
    # 1. No running loop
    # ──────────────────────────────
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(coro)

    # ──────────────────────────────
    # 2. Running loop
    # ──────────────────────────────
    if session is not None:
        raise ValueError(
            "run_sync called from within an async environment but a "
            "`session` bound to that loop was supplied.  Pass no session "
            "or call the async variant directly."
        )

    def _runner() -> T:
        return asyncio.run(coro)

    executor = (
        _executor_factory()
        if _executor_factory is not None
        else concurrent.futures.ThreadPoolExecutor(max_workers=1)
    )
    # Use a context manager only when we created the executor ourselves.
    if _executor_factory is None:
        with executor:
            future = executor.submit(_runner)
            return future.result()
    else:
        # In tests where a fake executor is injected we assume it stays alive.
        future = executor.submit(_runner)
        return future.result()


================================================
FILE: src/fastapi_poe/templates.py
================================================
"""

This module contains a collection of string templates designed to streamline the integration
of features like attachments into the LLM request.

"""

TEXT_ATTACHMENT_TEMPLATE = (
    """Below is the content of {attachment_name}:\n\n{attachment_parsed_content}"""
)
URL_ATTACHMENT_TEMPLATE = (
    """Assume you can access the external URL {attachment_name}. """
    """Use the URL's content below to respond to the queries:\n\n{content}"""
)
IMAGE_VISION_ATTACHMENT_TEMPLATE = (
    """I have uploaded an image ({filename}). """
    """Assume that you can see the attached image. """
    """First, read the image analysis:\n\n"""
    """<image_analysis>{parsed_image_description}</image_analysis>\n\n"""
    """Use any relevant parts to inform your response. """
    """Do NOT reference the image analysis in your response. """
    """Respond in the same language as my next message. """
)


================================================
FILE: src/fastapi_poe/types.py
================================================
import math
from typing import Any, Optional, Union, cast, get_args

from fastapi import Request
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing_extensions import Literal, TypeAlias

Identifier: TypeAlias = str
FeedbackType: TypeAlias = Literal["like", "dislike"]
ContentType: TypeAlias = Literal["text/markdown", "text/plain"]
MessageType: TypeAlias = Literal["function_call"]
ErrorType: TypeAlias = Literal[
    "user_message_too_long",
    "insufficient_fund",
    "user_caused_error",
    "privacy_authorization_error",
]


class MessageFeedback(BaseModel):
    """

    Feedback for a message as used in the Poe protocol.
    #### Fields:
    - `type` (`FeedbackType`)
    - `reason` (`Optional[str]`)

    """

    type: FeedbackType
    reason: Optional[str]


class CostItem(BaseModel):
    """

    An object representing a cost item used for authorization and charge request.
    #### Fields:
    - `amount_usd_milli_cents` (`int`)
    - `description` (`str`)

    """

    amount_usd_milli_cents: int
    description: Optional[str] = None

    @field_validator("amount_usd_milli_cents", mode="before")
    def validate_amount_is_int(cls, v: Union[int, str, float]) -> int:
        if isinstance(v, float):
            return math.ceil(v)
        if not isinstance(v, int):
            raise ValueError(
                "Invalid amount: expected an integer for amount_usd_milli_cents, "
                f"got {type(v)}. Please provide the amount in milli-cents "
                "(1/1000 of a cent) as a whole number. If you're working with a "
                "decimal value, consider using math.ceil() to round up."
            )
        return v


class Attachment(BaseModel):
    """

    Attachment included in a protocol message.
    #### Fields:
    - `url` (`str`): The download URL of the attachment.
    - `content_type` (`str`): The MIME type of the attachment.
    - `name` (`str`): The name of the attachment.
    - `inline_ref` (`Optional[str] = None`): Set this to make Poe render the attachment inline.
        You can then reference the attachment inline using ![title][inline_ref].
    - `parsed_content` (`Optional[str] = None`): The parsed content of the attachment.

    """

    url: str
    content_type: str
    name: str
    inline_ref: Optional[str] = None
    parsed_content: Optional[str] = None


class MessageReaction(BaseModel):
    """

    Reaction to a message.
    #### Fields:
    - `user_id` (`Identifier`): An anonymized identifier representing the
    user who reacted to the message.
    - `reaction` (`str`): The reaction to the message.

    """

    user_id: Identifier
    reaction: str


class Sender(BaseModel):
    """

    Sender of a message.
    #### Fields:
    - `id` (`Optional[Identifier] = None`): An anonymized identifier representing the sender.
    - `name` (`Optional[str] = None`): The name of the sender.
    If sender is a bot, this will be the name of the bot.
    If sender is a user, this will be the name of the user if user name is available for this chat.
    Typically, user name is only available in a chat of multiple users. Please note that a user
    can change their name anytime and different users with different `id` can share the same name.

    """

    id: Optional[Identifier] = None
    name: Optional[str] = None


class User(BaseModel):
    """

    User in a chat.
    #### Fields:
    - `id` (`Identifier`): An anonymized identifier representing a user.
    - `name` (`Optional[str] = None`): The name of the user if user name is available for this chat.
    Typically, user name is only available in a chat of multiple users. Please note that a user
    can change their name anytime and different users with different `id` can share the same name.

    """

    id: Identifier
    name: Optional[str] = None


class ProtocolMessage(BaseModel):
    """

    A message as used in the Poe protocol.
    #### Fields:
    - `role` (`Literal["system", "user", "bot", "tool"]`): Message sender role.
    - `message_type` (`Optional[MessageType] = None`): Type of the message.
    - `sender_id` (`Optional[str]`): Sender ID of the message. This is deprecated, use
      `sender` instead.
    - `sender` (`Optional[Sender] = None`): Sender of the message.
    - `content` (`str`): Content of the message.
    - `parameters` (`dict[str, Any] = {}`): Parameters for the message.
    - `content_type` (`ContentType="text/markdown"`): Content type of the message.
    - `timestamp` (`int = 0`): Timestamp of the message.
    - `message_id` (`str = ""`): Message ID for the message.
    - `feedback` (`list[MessageFeedback] = []`): Feedback for the message.
    - `attachments` (`list[Attachment] = []`): Attachments for the message.
    - `metadata` (`Optional[str] = None`): Metadata associated with the message.
    - `referenced_message` (`Optional["ProtocolMessage"] = None`): Message referenced by
      this message (if any).
    - `reactions` (`list[MessageReaction] = []`): Reactions to the message.

    """

    role: Literal["system", "user", "bot", "tool"]
    message_type: Optional[MessageType] = None
    sender_id: Optional[str] = None
    sender: Optional[Sender] = None
    content: str
    parameters: dict[str, Any] = {}
    content_type: ContentType = "text/markdown"
    timestamp: int = 0
    message_id: str = ""
    feedback: list[MessageFeedback] = Field(default_factory=list)
    attachments: list[Attachment] = Field(default_factory=list)
    metadata: Optional[str] = None
    referenced_message: Optional["ProtocolMessage"] = None
    reactions: list[MessageReaction] = Field(default_factory=list)


class RequestContext(BaseModel):
    class Config:
        arbitrary_types_allowed = True

    http_request: Request


class ParametersDefinition(BaseModel):
    """

    Parameters definition for function calling.
    #### Fields:
    - `type` (`str`)
    - `properties` (`dict[str, object]`)
    - `required` (`Optional[list[str]]`)

    """

    type: str  # noqa: A003
    properties: dict[str, object]
    required: Optional[list[str]] = None


class FunctionDefinition(BaseModel):
    """

    Function definition for OpenAI function calling.
    #### Fields:
    - `name` (`str`)
    - `description` (`str`)
    - `parameters` (`ParametersDefinition`)

    """

    name: str
    description: str
    parameters: ParametersDefinition


class ToolDefinition(BaseModel):
    """

    An object representing a tool definition used for OpenAI function calling.
    #### Fields:
    - `type` (`str`)
    - `function` (`FunctionDefinition`): Look at the source code for a detailed description
    of what this means.

    """

    type: str
    function: FunctionDefinition


class CustomToolDefinition(BaseModel):
    """Custom tool definition for OpenAI-compatible custom tools.

    Corresponds to `chat_completion_custom_tool_param.Custom` but
    with a looser format specification.

    """

    name: str
    description: Optional[str] = None
    format_: Optional[dict[str, Any]] = Field(default=None, alias="format")

    model_config = ConfigDict(populate_by_name=True)


class FunctionCallDefinition(BaseModel):
    """

    Function call definition for OpenAI function calling.
    #### Fields:
    - `name` (`str`)
    - `arguments` (`str`)

    """

    name: str
    arguments: str


class ToolCallDefinition(BaseModel):
    """

    An object representing a tool call. This is returned as a response by the model when using
    OpenAI function calling.
    #### Fields:
    - `id` (`str`)
    - `type` (`str`)
    - `function` (`FunctionCallDefinition`): The function name (string) and arguments (JSON string).

    """

    id: str
    type: str
    function: FunctionCallDefinition


class CustomCallDefinition(BaseModel):
    """Custom tool call in model response.

    Corresponds to `chat_completion_message_custom_tool_call.Custom`.

    """

    name: str
    input_: str = Field(alias="input")

    model_config = ConfigDict(populate_by_name=True)


class ToolResultDefinition(BaseModel):
    """

    An object representing a function result. This is passed to the model in the last step
    when using OpenAI function calling.
    #### Fields:
    - `role` (`str`)
    - `name` (`str`)
    - `tool_call_id` (`str`)
    - `content` (`str`)

    """

    role: str
    name: str
    tool_call_id: str
    content: str


class FunctionCallDefinitionDelta(BaseModel):
    """

    Function call definition delta for streaming OpenAI function calling.
    #### Fields:
    - `name` (`Optional[str]`)
    - `arguments` (`str`)

    """

    name: Optional[str] = None
    arguments: str


class ToolCallDefinitionDelta(BaseModel):
    """

    An object representing a tool call chunk. This is returned as a streamed response by the model
    when using OpenAI function calling. This may be an incomplete tool call definition (e.g. with
    the function name set with the arguments not yet filled in), so the index can be used to
    identify which tool call this chunk belongs to. Chunks may have null id, type, and
    function.name values.
    See https://platform.openai.com/docs/guides/function-calling#streaming for examples.
    #### Fields:
    - `index` (`int`): used to identify to which tool call this chunk belongs.
    - `id` (`Optional[str] = None`): The tool call ID. This helps the model identify previous tool
    call suggestions and help optimize tool call loops.
    - `type` (`Optional[str] = None`): The type of the tool call (always function for function
    calls).
    - `function` (`FunctionCallDefinitionDelta`): The function name (string) and arguments (JSON
    string).

    """

    index: int = 0
    id: Optional[str] = None
    type: Optional[str] = None
    function: FunctionCallDefinitionDelta


class BaseRequest(BaseModel):
    """Common data for all requests."""

    version: str
    type: Literal[
        "query", "settings", "report_feedback", "report_reaction", "report_error"
    ]


class QueryRequest(BaseRequest):
    """

    Request parameters for a query request.
    #### Fields:
    - `query` (`list[ProtocolMessage]`): list of message representing the current state of the chat.
    - `user_id` (`Identifier`): an anonymized identifier representing a user. This is persistent
    for subsequent requests from that user.
    - `conversation_id` (`Identifier`): an identifier representing a chat. This is
    persistent for subsequent request for that chat.
    - `message_id` (`Identifier`): an identifier representing a message.
    - `access_key` (`str = "<missing>"`): contains the access key defined when you created your bot
    on Poe.
    - `temperature` (`float | None = None`): Temperature input to be used for model inference.
    - `skip_system_prompt` (`bool = False`): Whether to use any system prompting or not.
    - `logit_bias` (`dict[str, float] = {}`)
    - `stop_sequences` (`list[str] = []`)
    - `adopt_current_bot_name` (`Optional[bool] = None`): Makes the called bot adopt
    the identity of the calling bot
    - `language_code` (`str = "en"`): BCP 47 language code of the user's client.
    - `bot_query_id` (`str = ""`): an identifier representing a bot query.
    - `users` (`list[User] = []`): list of users in the chat.
    - `tools` (`Optional[list[ToolDefinition]] = None`): List of tool definitions for
    function calling.
    - `tool_calls` (`Optional[list[ToolCallDefinition]] = None`): List of tool calls
    made by the assistant.
    - `tool_results` (`Optional[list[ToolResultDefinition]] = None`): List of tool
    execution results.
    - `query_creation_time` (`Optional[int] = None`): Timestamp when the query was
    created (microseconds).
    - `extra_params` (`Optional[dict[str, Any]] = None`): Additional parameters for
    the request.

    """

    query: list[ProtocolMessage]
    user_id: Identifier
    conversation_id: Identifier
    message_id: Identifier
    metadata: Identifier = ""
    api_key: str = "<missing>"
    access_key: str = "<missing>"
    temperature: Optional[float] = None
    skip_system_prompt: bool = False
    logit_bias: dict[str, float] = {}
    stop_sequences: list[str] = []
    language_code: str = "en"
    adopt_current_bot_name: Optional[bool] = None
    bot_query_id: Identifier = ""
    users: list[User] = []
    # Fields below are for compatibility with aiohttp_poe.types.QueryRequest
    tools: Optional[list[ToolDefinition]] = None
    tool_calls: Optional[list[ToolCallDefinition]] = None
    tool_results: Optional[list[ToolResultDefinition]] = None
    query_creation_time: Optional[int] = None
    extra_params: Optional[dict[str, Any]] = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "QueryRequest":
        """Create QueryRequest from a dictionary (e.g., from aiohttp_poe format)."""
        # Convert tool definitions if present
        if "tools" in data and data["tools"] is not None:
            data["tools"] = [
                ToolDefinition.model_validate(tool) if isinstance(tool, dict) else tool
                for tool in data["tools"]
            ]

        # Convert tool calls if present
        if "tool_calls" in data and data["tool_calls"] is not None:
            data["tool_calls"] = [
                ToolCallDefinition.model_validate(tc) if isinstance(tc, dict) else tc
                for tc in data["tool_calls"]
            ]

        # Convert tool results if present
        if "tool_results" in data and data["tool_results"] is not None:
            data["tool_results"] = [
                ToolResultDefinition.model_validate(tr) if isinstance(tr, dict) else tr
                for tr in data["tool_results"]
            ]

        return cls.model_validate(data)


class SettingsRequest(BaseRequest):
    """

    Request parameters for a settings request. Currently, this contains no fields but this
    might get updated in the future.

    """


class ReportFeedbackRequest(BaseRequest):
    """

    Request parameters for a report_feedback request.
    #### Fields:
    - `message_id` (`Identifier`)
    - `user_id` (`Identifier`)
    - `conversation_id` (`Identifier`)
    - `feedback_type` (`FeedbackType`)

    """

    message_id: Identifier
    user_id: Identifier
    conversation_id: Identifier
    feedback_type: FeedbackType


class ReportReactionRequest(BaseRequest):
    """

    Request parameters for a report_reaction request.
    #### Fields:
    - `message_id` (`Identifier`)
    - `user_id` (`Identifier`)
    - `conversation_id` (`Identifier`)
    - `reaction` (`str`)

    """

    message_id: Identifier
    user_id: Identifier
    conversation_id: Identifier
    reaction: str


class ReportErrorRequest(BaseRequest):
    """

    Request parameters for a report_error request.
    #### Fields:
    - `message` (`str`)
    - `metadata` (`dict[str, Any]`)

    """

    message: str
    metadata: dict[str, Any]


Number = Union[int, float]


class Divider(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["divider"] = "divider"


class TextField(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["text_field"] = "text_field"
    label: str
    description: Optional[str] = None
    parameter_name: str
    default_value: Optional[str] = None
    placeholder: Optional[str] = None


class TextArea(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["text_area"] = "text_area"
    label: str
    description: Optional[str] = None
    parameter_name: str
    default_value: Optional[str] = None
    placeholder: Optional[str] = None


class ValueNamePair(BaseModel):
    model_config = ConfigDict(extra="forbid")

    value: str
    name: str


class DropDown(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["drop_down"] = "drop_down"
    label: str
    description: Optional[str] = None
    parameter_name: str
    default_value: Optional[str] = None
    options: list[ValueNamePair]


class ToggleSwitch(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["toggle_switch"] = "toggle_switch"
    label: str
    description: Optional[str] = None
    parameter_name: str
    default_value: Optional[bool] = None


class Slider(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["slider"] = "slider"
    label: str
    description: Optional[str] = None
    parameter_name: str
    default_value: Optional[Number] = None
    min_value: Number
    max_value: Number
    step: Number


class AspectRatioOption(BaseModel):
    model_config = ConfigDict(extra="forbid")

    value: Optional[str] = None
    width: Number
    height: Number


class AspectRatio(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["aspect_ratio"] = "aspect_ratio"
    label: str
    description: Optional[str] = None
    parameter_name: str
    default_value: Optional[str] = None
    options: list[AspectRatioOption]


BaseControl = Union[
    Divider, TextField, TextArea, DropDown, ToggleSwitch, Slider, AspectRatio
]


class LiteralValue(BaseModel):
    model_config = ConfigDict(extra="forbid")

    literal: Union[str, float, int, bool]


class ParameterValue(BaseModel):
    model_config = ConfigDict(extra="forbid")

    parameter_name: str


class ComparatorCondition(BaseModel):
    model_config = ConfigDict(extra="forbid")

    comparator: Literal["eq", "ne", "gt", "ge", "lt", "le"]
    left: Union[LiteralValue, ParameterValue]
    right: Union[LiteralValue, ParameterValue]


class ConditionallyRenderControls(BaseModel):
    model_config = ConfigDict(extra="forbid")

    control: Literal["condition"] = "condition"
    condition: ComparatorCondition
    controls: list[BaseControl]


FullControls = Union[
    Divider,
    TextField,
    TextArea,
    DropDown,
    ToggleSwitch,
    Slider,
    AspectRatio,
    ConditionallyRenderControls,
]


class Tab(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: Optional[str] = None
    controls: list[FullControls]


class Section(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: Optional[str] = None
    controls: Optional[list[FullControls]] = None
    tabs: Optional[list[Tab]] = None
    collapsed_by_default: Optional[bool] = None


class ParameterControls(BaseModel):
    model_config = ConfigDict(extra="forbid")

    api_version: Literal["2"] = "2"
    sections: list[Section]


class SettingsResponse(BaseModel):
    """

    An object representing your bot's response to a settings object.
    #### Fields:
    - `response_version` (`int = 2`): Different Poe Protocol versions use different default settings
    values. When provided, Poe will use the default values for the specified response version.
    If not provided, Poe will use the default values for response version 0.
    - `server_bot_dependencies` (`dict[str, int] = {}`): Information about other bots that your bot
    uses. This is used to facilitate the Bot Query API.
    - `allow_attachments` (`bool = True`): Whether to allow users to upload attachments to your
    bot.
    - `introduction_message` (`str = ""`): The introduction message to display to the users of your
    bot.
    - `expand_text_attachments` (`bool = True`): Whether to request parsed content/descriptions from
    text attachments with the query request. This content is sent through the new parsed_content
    field in the attachment dictionary. This change makes enabling file uploads much simpler.
    - `enable_image_comprehension` (`bool = False`): Similar to `expand_text_attachments` but for
    images.
    - `enforce_author_role_alternation` (`bool = False`): If enabled, Poe will concatenate messages
    so that they follow role alternation, which is a requirement for certain LLM providers like
    Anthropic.
    - `enable_multi_entity_prompting` (`bool = True`): If enabled, Poe will combine previous bot
    messages if there is a multientity context.
    - `parameter_controls` (`Optional[ParameterControls] = None`): Optional JSON object that defines
    interactive parameter controls. The object must contain an api_version and sections array.

    """

    model_config = ConfigDict(extra="forbid")

    response_version: Optional[int] = 2
    context_clear_window_secs: Optional[int] = None  # deprecated
    allow_user_context_clear: Optional[bool] = None  # deprecated
    custom_rate_card: Optional[str] = None  # deprecated
    server_bot_dependencies: dict[str, int] = Field(default_factory=dict)
    allow_attachments: Optional[bool] = None
    introduction_message: Optional[str] = None
    expand_text_attachments: Optional[bool] = None
    enable_image_comprehension: Optional[bool] = None
    enforce_author_role_alternation: Optional[bool] = None
    enable_multi_bot_chat_prompting: Optional[bool] = None  # deprecated
    enable_multi_entity_prompting: Optional[bool] = None
    rate_card: Optional[str] = None
    cost_label: Optional[str] = None
    parameter_controls: Optional[ParameterControls] = None


class AttachmentUploadResponse(BaseModel):
    """

    The result of a post_message_attachment request or file event in bot response.
    #### Fields:
    - `attachment_url` (`Optional[str]`): The URL of the attachment.
    - `mime_type` (`Optional[str]`): The MIME type of the attachment.
    - `name` (`Optional[str]`): The name of the attachment. Only populated when
    the attachment originates from a file event in bot response, not from
    post_message_attachment.
    - `inline_ref` (`Optional[str]`): The inline reference of the attachment.
    if post_message_attachment is called with is_inline=False, this will be None.

    """

    attachment_url: Optional[str]
    mime_type: Optional[str]
    name: Optional[str] = None
    inline_ref: Optional[str]

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "AttachmentUploadResponse":
        """Create an AttachmentUploadResponse from a dictionary (for aiohttp_poe FileEvent)."""
        return cls(
            attachment_url=data.get("url"),  # type: ignore
            mime_type=data.get("content_type"),  # type: ignore
            name=data.get("name"),  # type: ignore
            inline_ref=data.get("inline_ref"),  # type: ignore
        )


class AttachmentHttpResponse(BaseModel):
    attachment_url: Optional[str]
    mime_type: Optional[str]


class DataResponse(BaseModel):
    """

    A response that contains arbitrary data to attach to the bot response.
    This data can be retrieved in later requests to the bot within the same chat.
    Note that only the final DataResponse object in the stream will be attached to the bot response.

    #### Fields:
    - `metadata` (`str`): String of data to attach to the bot response.

    """

    model_config = ConfigDict(extra="forbid")

    metadata: str


class PartialResponse(BaseModel):
    """

    Representation of a (possibly partial) response from a bot. Yield this in
    `PoeBot.get_response` or `PoeBot.get_response_with_context` to communicate your response to Poe.

    #### Fields:
    - `text` (`str`): The actual text you want to display to the user. Note that this should solely
    be the text in the next token since Poe will automatically concatenate all tokens before
    displaying the response to the user.
    - `data` (`Optional[dict[str, Any]]`): Used to send arbitrary json data to Poe. This is
    currently only used for OpenAI function calling.
    - `is_suggested_reply` (`bool = False`): Setting this to true will create a suggested reply with
    the provided text value.
    - `is_replace_response` (`bool = False`): Setting this to true will clear out the previously
    displayed text to the user and replace it with the provided text value.

    """

    # These objects are usually instantiated in user code, so we
    # disallow extra fields to prevent mistakes.
    model_config = ConfigDict(extra="forbid")

    text: str
    """Partial response text.

    If the final bot response is "ABC", you may see a sequence
    of PartialResponse objects like PartialResponse(text="A"),
    PartialResponse(text="B"), PartialResponse(text="C").

    """

    data: Optional[dict[str, Any]] = None
    """Used when a bot returns the json event."""

    raw_response: object = None
    """For debugging, the raw response from the bot."""

    full_prompt: Optional[str] = None
    """For debugging, contains the full prompt as sent to the bot."""

    request_id: Optional[str] = None
    """May be set to an internal identifier for the request."""

    is_suggested_reply: bool = False
    """If true, this is a suggested reply."""

    is_replace_response: bool = False
    """If true, this text should completely replace the previous bot text."""

    attachment: Optional[Attachment] = None
    """If the bot returns an attachment, it will be contained here."""

    tool_calls: list[ToolCallDefinitionDelta] = Field(default_factory=list)
    """If the bot returns tool calls, it will be contained here."""

    index: Optional[int] = None
    """If a bot supports multiple responses, this is the index of the response to be updated."""

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "PartialResponse":
        """Create a PartialResponse from a dictionary (for aiohttp_poe TextEvent)."""
        return cls(text=str(data.get("text", "")))


class ErrorResponse(PartialResponse):
    """

    Similar to `PartialResponse`. Yield this to communicate errors from your bot.

    #### Fields:
    - `allow_retry` (`bool = True`): Whether or not to allow a user to retry on error.
    - `error_type` (`Optional[ErrorType] = None`): An enum indicating what error to display.

    """

    allow_retry: bool = True
    error_type: Optional[ErrorType] = None

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "ErrorResponse":
        """Create an ErrorResponse from a dictionary (for aiohttp_poe ErrorEvent)."""
        text = data.get("text", "")
        allow_retry = data.get("allow_retry", True)
        error_type_raw = data.get("error_type")
        error_type: Optional[ErrorType] = None
        if isinstance(error_type_raw, str) and error_type_raw in get_args(ErrorType):
            error_type = cast(ErrorType, error_type_raw)

        return cls(text=str(text), allow_retry=bool(allow_retry), error_type=error_type)


class MetaResponse(PartialResponse):
    """

    Similar to `Partial Response`. Yield this to communicate `meta` events from server bots.

    #### Fields:
    - `suggested_replies` (`bool = False`): Whether or not to enable suggested replies.
    - `content_type` (`ContentType = "text/markdown"`): Used to describe the format of the response.
    The currently supported values are `text/plain` and `text/markdown`.
    - `refetch_settings` (`bool = False`): Used to trigger a settings fetch request from Poe. A more
    robust way to trigger this is documented at:
    https://creator.poe.com/docs/server-bots/updating-bot-settings

    """

    linkify: bool = True  # deprecated
    suggested_replies: bool = True
    content_type: ContentType = "text/markdown"
    refetch_settings: bool = False


================================================
FILE: tests/test_base.py
================================================
import json
from collections.abc import AsyncIterable, AsyncIterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Callable, Union
from unittest.mock import AsyncMock, Mock, patch

import httpx
import pytest
from fastapi import Request
from fastapi_poe.base import CostRequestError, InsufficientFundError, PoeBot, make_app
from fastapi_poe.client import AttachmentUploadError
from fastapi_poe.templates import (
    IMAGE_VISION_ATTACHMENT_TEMPLATE,
    TEXT_ATTACHMENT_TEMPLATE,
    URL_ATTACHMENT_TEMPLATE,
)
from fastapi_poe.types import (
    Attachment,
    AttachmentUploadResponse,
    CostItem,
    DataResponse,
    ErrorResponse,
    MetaResponse,
    PartialResponse,
    ProtocolMessage,
    QueryRequest,
    RequestContext,
    Sender,
)
from sse_starlette import ServerSentEvent
from starlette.routing import Route


@pytest.fixture
def basic_bot() -> PoeBot:
    mock_bot = PoeBot(path="/bot/test_bot", bot_name="test_bot", access_key="123")

    async def get_response(
        request: QueryRequest,
    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:
        yield MetaResponse(
            text="",
            suggested_replies=True,
            content_type="text/markdown",
            refetch_settings=False,
        )
        yield PartialResponse(text="hello")
        yield PartialResponse(text="this is a suggested reply", is_suggested_reply=True)
        yield PartialResponse(
            text="this is a replace response", is_replace_response=True
        )
        yield DataResponse(metadata='{"foo": "bar"}')

    mock_bot.get_response = get_response
    return mock_bot


@pytest.fixture
def attachment_bot() -> PoeBot:
    mock_bot = PoeBot(
        path="/bot/attachment_bot", bot_name="attachment_bot", access_key="123"
    )

    async def get_response(
        request: QueryRequest,
    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:
        yield PartialResponse(text="Generating... (1s elapsed)")
        yield PartialResponse(
            text="Generating... (2s elapsed)", is_replace_response=True
        )
        yield PartialResponse(
            text="Generating... (3s elapsed)", is_replace_response=True
        )
        inline_ref = "abc"
        yield PartialResponse(
            text=f"![image][{inline_ref}]",
            attachment=Attachment(
                url="https://pfst.cf2.poecdn.net/base/image/cat.jpg",
                name="cat.jpg",
                content_type="image/jpeg",
                inline_ref=inline_ref,
            ),
            is_replace_response=True,
        )
        # test a non-inline attachment
        yield PartialResponse(
            text="",
            attachment=Attachment(
                url="https://pfst.cf2.poecdn.net/base/application/test.pdf",
                name="test.pdf",
                content_type="application/pdf",
            ),
        )

    mock_bot.get_response = get_response
    return mock_bot


@pytest.fixture
def error_bot() -> PoeBot:
    mock_bot = PoeBot(path="/bot/error_bot", bot_name="error_bot", access_key="123")

    async def get_response(
        request: QueryRequest,
    ) -> AsyncIterable[Union[PartialResponse, ServerSentEvent, DataResponse]]:
        yield PartialResponse(text="hello")
        yield ErrorResponse(text="sample error", allow_retry=True)

    mock_bot.get_response = get_response
    return mock_bot


@pytest.fixture
def mock_request() -> QueryRequest:
    return QueryRequest(
        version="1.0",
        type="query",
        query=[ProtocolMessage(role="user", content="Hello, world!", sender=Sender())],
        user_id="123",
        conversation_id="123",
        message_id="456",
        bot_query_id="123",
    )


@pytest.fixture
def mock_request_context() -> RequestContext:
    return RequestContext(http_request=Mock(spec=Request))


class TestPoeBot:

    @pytest.mark.asyncio
    async def test_handle_query_basic_bot(
        self,
        basic_bot: PoeBot,
        mock_request: QueryRequest,
        mock_request_context: RequestContext,
    ) -> None:
        expected_sse_events = [
            ServerSentEvent(
                event="meta",
                data=json.dumps(
                    {
                        "suggested_replies": True,
                        "content_type": "text/markdown",
                        "refetch_settings": False,
                        "linkify": True,
                    }
                ),
            ),
            ServerSentEvent(event="text", data=json.dumps({"text": "hello"})),
            ServerSentEvent(
                event="suggested_reply",
                data=json.dumps({"text": "this is a suggested reply"}),
            ),
            ServerSentEvent(
                event="replace_response",
                data=json.dumps({"text": "this is a replace response"}),
            ),
            ServerSentEvent(
                event="data", data=json.dumps({"metadata": '{"foo": "bar"}'})
            ),
            ServerSentEvent(event="done", data="{}"),
        ]
        actual_sse_events = [
            event
            async for event in basic_bot.handle_query(
                mock_request, mock_request_context
            )
        ]
        assert len(actual_sse_events) == len(expected_sse_events)

        for actual_event, expected_event in zip(actual_sse_events, expected_sse_events):
            assert actual_event.event == expected_event.event
            assert expected_event.data and actual_event.data
            assert json.loads(actual_event.data) == json.loads(expected_event.data)

    @pytest.mark.asyncio
    async def test_handle_query_error_bot(
        self,
        error_bot: PoeBot,
        mock_request: QueryRequest,
        mock_request_context: RequestContext,
    ) -> None:
        expected_sse_events_error = [
            ServerSentEvent(event="text", data=json.dumps({"text": "hello"})),
            ServerSentEvent(
                event="error",
                data=json.dumps({"text": "sample error", "allow_retry": True}),
            ),
            ServerSentEvent(event="done", data="{}"),
        ]
        actual_sse_events = [
            event
            async for event in error_bot.handle_query(
                mock_request, mock_request_context
            )
        ]
        assert len(actual_sse_events) == len(expected_sse_events_error)

        for actual_event, expected_event in zip(
            actual_sse_events, expected_sse_events_error
        ):
            assert actual_event.event == expected_event.event
            assert expected_event.data and actual_event.data
            assert json.loads(actual_event.data) == json.loads(expected_event.data)

    def test_insert_attachment_messages(self, basic_bot: PoeBot) -> None:
        # Create mock attachments
        mock_text_attachment = Attachment(
            url="https://pfst.cf2.poecdn.net/base/text/test.txt",
            name="test.txt",
            content_type="text/plain",
            parsed_content="Hello, world!",
        )
        mock_image_attachment = Attachment(
            url="https://pfst.cf2.poecdn.net/base/image/test.png",
            name="test.png",
            content_type="image/png",
            parsed_content="test.png***Hello, world!",
        )
        mock_image_attachment_2 = Attachment(
            url="https://pfst.cf2.poecdn.net/base/image/test.png",
            name="testimage2.jpg",
            content_type="image/jpeg",
            parsed_content="Hello, world!",
        )
        mock_pdf_attachment = Attachment(
            url="https://pfst.cf2.poecdn.net/base/application/test.pdf",
            name="test.pdf",
            content_type="application/pdf",
            parsed_content="Hello, world!",
        )
        mock_html_attachment = Attachment(
            url="https://pfst.cf2.poecdn.net/base/text/test.html",
            name="test.html",
            content_type="text/html",
            parsed_content="Hello, world!",
        )
        mock_video_attachment = Attachment(
            url="https://pfst.cf2.poecdn.net/base/video/test.mp4",
            name="test.mp4",
            content_type="video/mp4",
            parsed_content="Hello, world!",
        )
        # Create mock protocol messages
        message_without_attachments = ProtocolMessage(
            role="user", content="Hello, world!", sender=Sender()
        )
        message_with_attachments = ProtocolMessage(
            role="user",
            content="Here's some attachments",
            sender=Sender(),
            attachments=[
                mock_text_attachment,
                mock_image_attachment,
                mock_image_attachment_2,
                mock_pdf_attachment,
                mock_html_attachment,
                mock_video_attachment,
            ],
        )
        # Create mock query request
        mock_query_request = QueryRequest(
            version="1.0",
            type="query",
            query=[message_without_attachments, message_with_attachments],
            user_id="123",
            conversation_id="123",
            message_id="456",
        )

        assert (
            mock_image_attachment.parsed_content
        )  # satisfy pyright so split() works below
        expected_protocol_messages = [
            message_without_attachments,
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content=TEXT_ATTACHMENT_TEMPLATE.format(
                    attachment_name=mock_text_attachment.name,
                    attachment_parsed_content=mock_text_attachment.parsed_content,
                ),
            ),
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content=TEXT_ATTACHMENT_TEMPLATE.format(
                    attachment_name=mock_pdf_attachment.name,
                    attachment_parsed_content=mock_pdf_attachment.parsed_content,
                ),
            ),
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content=URL_ATTACHMENT_TEMPLATE.format(
                    attachment_name=mock_html_attachment.name,
                    content=mock_html_attachment.parsed_content,
                ),
            ),
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content=IMAGE_VISION_ATTACHMENT_TEMPLATE.format(
                    filename=mock_image_attachment.parsed_content.split("***")[0],
                    parsed_image_description=mock_image_attachment.parsed_content.split(
                        "***"
                    )[1],
                ),
            ),
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content=IMAGE_VISION_ATTACHMENT_TEMPLATE.format(
                    filename=mock_image_attachment_2.name,
                    parsed_image_description=mock_image_attachment_2.parsed_content,
                ),
            ),
            message_with_attachments,
        ]

        modified_query_request = basic_bot.insert_attachment_messages(
            mock_query_request
        )
        protocol_messages = modified_query_request.query

        assert protocol_messages == expected_protocol_messages

    def test_make_prompt_author_role_alternated(self, basic_bot: PoeBot) -> None:
        mock_protocol_messages = [
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content="Hello, world!",
                attachments=[
                    Attachment(
                        url="https://pfst.cf2.poecdn.net/base/text/test.txt",
                        name="test.txt",
                        content_type="text/plain",
                        parsed_content="Hello, world!",
                    )
                ],
            ),
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content="Hello, world!",
                attachments=[
                    Attachment(
                        url="https://pfst.cf2.poecdn.net/base/text/test2.txt",
                        name="test2.txt",
                        content_type="text/plain",
                        parsed_content="Bye!",
                    )
                ],
            ),
            ProtocolMessage(role="bot", sender=Sender(), content="Hello, world!"),
        ]
        expected_protocol_messages = [
            ProtocolMessage(
                role="user",
                sender=Sender(),
                content="Hello, world!\n\nHello, world!",
                attachments=[
                    Attachment(
                        url="https://pfst.cf2.poecdn.net/base/text/test2.txt",
                        name="test2.txt",
                        content_type="text/plain",
                        parsed_content="Bye!",
                    ),
                    Attachment(
                        url="https://pfst.cf2.poecdn.net/base/text/test.txt",
                        name="test.txt",
                        content_type="text/plain",
                        parsed_content="Hello, world!",
                    ),
                ],
            ),
            ProtocolMessage(role="bot", sender=Sender(), content="Hello, world!"),
        ]
        assert (
            basic_bot.make_prompt_author_role_alternated(mock_protocol_messages)
            == expected_protocol_messages
        )


class TestPoeBotWithAttachments:

    @pytest.mark.asyncio
    async def test_handle_query_attachment_bot_basic(
        self,
        attachment_bot: PoeBot,
        mock_request: QueryRequest,
        mock_request_context: RequestContext,
    ) -> None:
        expected_sse_events = [
            ServerSentEvent(
                event="text", data=json.dumps({"text": "Generating... (1s elapsed)"})
            ),
            ServerSentEvent(
                event="replace_response",
                data=json.dumps({"text": "Generating... (2s elapsed)"}),
            ),
            ServerSentEvent(
                event="replace_response",
                data=json.dumps({"text": "Generating... (3s elapsed)"}),
            ),
            ServerSentEvent(
                event="file",
                data=json.dumps(
                    {
                        "url": "https://pfst.cf2.poecdn.net/base/image/cat.jpg",
                        "content_type": "image/jpeg",
                        "name": "cat.jpg",
                        "inline_ref": "abc",
                    }
                ),
            ),
            ServerSentEvent(
                event="replace_response", data=json.dumps({"text": "![image][abc]"})
            ),
            ServerSentEvent(
                event="file",
                data=json.dumps(
                    {
                        "url": "https://pfst.cf2.poecdn.net/base/application/test.pdf",
                        "content_type": "application/pdf",
                        "name": "test.pdf",
                        "inline_ref": None,
                    }
                ),
            ),
            ServerSentEvent(event="text", data=json.dumps({"text": ""})),
            ServerSentEvent(event="done", data="{}"),
        ]
        actual_sse_events = [
            event
            async for event in attachment_bot.handle_query(
                mock_request, mock_request_context
            )
        ]
        assert len(actual_sse_events) == len(expected_sse_events)

        for actual_event, expected_event in zip(actual_sse_events, expected_sse_events):
            assert actual_event.event == expected_event.event
            assert expected_event.data and actual_event.data
            assert json.loads(actual_event.data) == json.loads(expected_event.data)


class TestPostMessageAttachment:

    @pytest.mark.asyncio
    @patch("httpx.AsyncClient.send")
    async def test_post_message_attachment_basic(
        self, mock_send: Mock, basic_bot: PoeBot
    ) -> None:
        mock_send.return_value = httpx.Response(
            200,
            json={
                "attachment_url": "https://pfst.cf2.poecdn.net/base/text/test.txt",
                "mime_type": "text/plain",
            },
        )

        result = await basic_bot.post_message_attachment(
            message_id="123",
            download_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
            download_filename="test.txt",
        )

        assert result == AttachmentUploadResponse(
            inline_ref=None,
            attachment_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
            mime_type="text/plain",
        )
        file_events_to_yield = basic_bot._file_events_to_yield.get("123", [])
        assert len(file_events_to_yield) == 1
        assert file_events_to_yield.pop().data == json.dumps(
            {
                "url": "https://pfst.cf2.poecdn.net/base/text/test.txt",
                "content_type": "text/plain",
                "name": "test.txt",
                "inline_ref": None,
            }
        )

    @pytest.mark.asyncio
    @patch("httpx.AsyncClient.send")
    async def test_post_message_attachment_download_url(
        self, mock_send: Mock, basic_bot: PoeBot
    ) -> None:
        mock_send.return_value = httpx.Response(
            200,
            json={
                "attachment_url": "https://pfst.cf2.poecdn.net/base/text/test.txt",
                "mime_type": "text/plain",
            },
        )

        result = await basic_bot.post_message_attachment(
            message_id="123",
            download_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
        )

        assert result == AttachmentUploadResponse(
            inline_ref=None,
            attachment_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
            mime_type="text/plain",
        )
        file_events_to_yield = basic_bot._file_events_to_yield.get("123", [])
        assert len(file_events_to_yield) == 1
        assert file_events_to_yield.pop().data == json.dumps(
            {
                "url": "https://pfst.cf2.poecdn.net/base/text/test.txt",
                "content_type": "text/plain",
                "name": "test.txt",  # extracted from url
                "inline_ref": None,
            }
        )

    @pytest.mark.asyncio
    @patch("httpx.AsyncClient.send")
    @patch("fastapi_poe.base.generate_inline_ref")
    async def test_post_message_attachment_inline(
        self, mock_generate_inline_ref: Mock, mock_send: Mock, basic_bot: PoeBot
    ) -> None:
        mock_send.return_value = httpx.Response(
            200,
            json={
                "attachment_url": "https://pfst.cf2.poecdn.net/base/text/test.txt",
                "mime_type": "text/plain",
            },
        )
        mock_generate_inline_ref.return_value = "ab32ef21"

        result = await basic_bot.post_message_attachment(
            message_id="123",
            download_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
            download_filename="test.txt",
            is_inline=True,
        )

        assert result == AttachmentUploadResponse(
            inline_ref="ab32ef21",
            attachment_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
            mime_type="text/plain",
        )

        # Add a second attachment
        mock_send.return_value = httpx.Response(
            200,
            json={
                "attachment_url": "https://pfst.cf2.poecdn.net/base/image/test.png",
                "mime_type": "image/png",
            },
        )

        result = await basic_bot.post_message_attachment(
            message_id="123",
            download_url="https://pfst.cf2.poecdn.net/base/image/test.png",
            download_filename="test.png",
            is_inline=False,
        )

        assert result == AttachmentUploadResponse(
            inline_ref=None,
            attachment_url="https://pfst.cf2.poecdn.net/base/image/test.png",
            mime_type="image/png",
        )
        # Check that the file events are added to the instance dictionary
        file_events_to_yield = basic_bot._file_events_to_yield.get("123", [])
        assert len(file_events_to_yield) == 2
        expected_items = [
            {
                "url": "https://pfst.cf2.poecdn.net/base/text/test.txt",
                "content_type": "text/plain",
                "name": "test.txt",
                "inline_ref": "ab32ef21",
            },
            {
                "url": "https://pfst.cf2.poecdn.net/base/image/test.png",
                "content_type": "image/png",
                "name": "test.png",
                "inline_ref": None,
            },
        ]
        expected_items_json = {json.dumps(item) for item in expected_items}
        actual_items_json = {file_event.data for file_event in file_events_to_yield}
        assert expected_items_json == actual_items_json

    @pytest.mark.asyncio
    @patch("httpx.AsyncClient.send")
    async def test_post_message_attachment_error(
        self, mock_send: Mock, basic_bot: PoeBot
    ) -> None:
        mock_send.return_value = httpx.Response(400, json={"error": "test"})
        with pytest.raises(AttachmentUploadError):
            await basic_bot.post_message_attachment(
                message_id="123",
                download_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
                download_filename="test.txt",
            )

        with pytest.raises(ValueError):
            await basic_bot.post_message_attachment(
                message_id="123",
                download_url="https://pfst.cf2.poecdn.net/base/text/test.txt",
                download_filename="test.txt",
                file_data=b"test",
                filename="test.txt",
            )


class TestCostCapture:

    def create_sse_mock(
        self,
        events: list[ServerSentEvent],
        status_code: int = 200,
        reason_phrase: str = "OK",
    ) -> Callable[..., AbstractAsyncContextManager[AsyncMock]]:
        @asynccontextmanager
        async def mock_sse_connection(
            *args: Any, **kwargs: Any  # noqa: ANN401
        ) -> AsyncIterator[AsyncMock]:
            mock_source = AsyncMock()
            mock_source.response.status_code = status_code
            mock_source.response.reason_phrase = reason_phrase

            async def mock_aiter_sse() -> AsyncIterator[ServerSentEvent]:
                for event in events:
                    yield event

            mock_source.aiter_sse = mock_aiter_sse
            yield mock_source

        return mock_sse_connection

    @pytest.mark.asyncio
    async def test_authorize_cost_success(
        self, basic_bot: PoeBot, mock_request: QueryRequest
    ) -> None:
        cost_item = CostItem(amount_usd_milli_cents=1000)
        url = "https://example.com"

        events = [ServerSentEvent(event="result", data='{"status": "success"}')]

        with patch("httpx_sse.aconnect_sse") as mock_connect_sse:
            mock_connect_sse.side_effect = self.create_sse_mock(
                events, status_code=200, reason_phrase="OK"
            )
            await basic_bot.authorize_cost(
                request=mock_request, amounts=cost_item, base_url=url
            )

            mock_connect_sse.assert_called_once()
            call_args = mock_connect_sse.call_args
            assert (
                call_args.kwargs["url"]
                == f"{url}bot/cost/{mock_request.bot_query_id}/authorize"
            )
            assert call_args.kwargs["json"]["amounts"] == [cost_item.model_dump()]
            assert call_args.kwargs["json"]["access_key"] == basic_bot.access_key

    @pytest.mark.asyncio
    async def test_authorize_cost_failure(
        self, basic_bot: PoeBot, mock_request: QueryRequest
    ) -> None:
        cost_item = CostItem(amount_usd_milli_cents=1000)
        url = "https://example.com"

        events = [
            ServerSentEvent(event="result", data='{"status": "insufficient funds"}')
        ]

        with patch("httpx_sse.aconnect_sse") as mock_connect_sse:
            mock_connect_sse.side_effect = self.create_sse_mock(
                events, status_code=400, reason_phrase="Bad Request"
            )
            with pytest.raises(CostRequestError):
                await basic_bot.authorize_cost(
                    request=mock_request, amounts=cost_item, base_url=url
                )

        with patch("httpx_sse.aconnect_sse") as mock_connect_sse:
            mock_connect_sse.side_effect = self.create_sse_mock(
                events, status_code=200, reason_phrase="OK"
            )
            with pytest.raises(InsufficientFundError):
                await basic_bot.authorize_cost(
                    request=mock_request, amounts=cost_item, base_url=url
                )

    @pytest.mark.asyncio
    async def test_capture_cost_success(
        self, basic_bot: PoeBot, mock_request: QueryRequest
    ) -> None:
        cost_item = CostItem(amount_usd_milli_cents=1000)
        url = "https://example.com"

        events = [ServerSentEvent(event="result", data='{"status": "success"}')]

        with patch("httpx_sse.aconnect_sse") as mock_connect_sse:
            mock_connect_sse.side_effect = self.create_sse_mock(
                events, status_code=200, reason_phrase="OK"
            )
            await basic_bot.capture_cost(
                request=mock_request, amounts=cost_item, base_url=url
            )

            mock_connect_sse.assert_called_once()
            call_args = mock_connect_sse.call_args
            assert (
                call_args.kwargs["url"]
                == f"{url}bot/cost/{mock_request.bot_query_id}/capture"
            )
            assert call_args.kwargs["json"]["amounts"] == [cost_item.model_dump()]
            assert call_args.kwargs["json"]["access_key"] == basic_bot.access_key

    @pytest.mark.asyncio
    async def test_capture_cost_failure(
        self, basic_bot: PoeBot, mock_request: QueryRequest
    ) -> None:
        cost_item = CostItem(amount_usd_milli_cents=1000)
        url = "https://example.com"

        events = [
            ServerSentEvent(event="result", data='{"status": "insufficient funds"}')
        ]

        with patch("httpx_sse.aconnect_sse") as mock_connect_sse:
            mock_connect_sse.side_effect = self.create_sse_mock(
                events, status_code=400, reason_phrase="Bad Request"
            )
            with pytest.raises(CostRequestError):
                await basic_bot.capture_cost(
                    request=mock_request, amounts=cost_item, base_url=url
                )

        with patch("httpx_sse.aconnect_sse") as mock_connect_sse:
            mock_connect_sse.side_effect = self.create_sse_mock(
                events, status_code=200, reason_phrase="OK"
            )
            with pytest.raises(InsufficientFundError):
                await basic_bot.capture_cost(
                    request=mock_request, amounts=cost_item, base_url=url
                )


def test_make_app(basic_bot: PoeBot,
Download .txt
gitextract_eeb9sykf/

├── .flake8
├── .github/
│   ├── CODEOWNERS
│   ├── pull_request_template.md
│   └── workflows/
│       ├── lint.yml
│       └── publish.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .prettierignore
├── .prettierrc.yaml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── docs/
│   ├── api_reference.md
│   └── generate_api_reference.py
├── pyproject.toml
├── src/
│   └── fastapi_poe/
│       ├── __init__.py
│       ├── base.py
│       ├── client.py
│       ├── py.typed
│       ├── sync_utils.py
│       ├── templates.py
│       └── types.py
└── tests/
    ├── test_base.py
    ├── test_client.py
    ├── test_sync_utils.py
    └── test_types.py
Download .txt
SYMBOL INDEX (256 symbols across 9 files)

FILE: docs/generate_api_reference.py
  class DocumentationData (line 32) | class DocumentationData:
  function _unwrap_func (line 39) | def _unwrap_func(func_obj: Union[staticmethod, Callable]) -> Callable:
  function get_documentation_data (line 46) | def get_documentation_data(
  function generate_documentation (line 77) | def generate_documentation(

FILE: src/fastapi_poe/base.py
  class InvalidParameterError (line 60) | class InvalidParameterError(Exception):
  class CostRequestError (line 64) | class CostRequestError(Exception):
  class InsufficientFundError (line 68) | class InsufficientFundError(Exception):
  class LoggingMiddleware (line 72) | class LoggingMiddleware(BaseHTTPMiddleware):  # pragma: no cover
    method set_body (line 73) | async def set_body(self, request: Request) -> None:
    method dispatch (line 81) | async def dispatch(
  function http_exception_handler (line 107) | async def http_exception_handler(request: Request, ex: Exception) -> Res...
  function generate_inline_ref (line 115) | def generate_inline_ref() -> str:
  function get_filename_from_url (line 119) | def get_filename_from_url(url: str) -> str:
  class PoeBot (line 127) | class PoeBot:
    method get_response (line 160) | async def get_response(
    method get_response_with_context (line 184) | async def get_response_with_context(
    method get_settings (line 207) | async def get_settings(self, setting: SettingsRequest) -> SettingsResp...
    method get_settings_with_context (line 221) | async def get_settings_with_context(
    method on_feedback (line 240) | async def on_feedback(self, feedback_request: ReportFeedbackRequest) -...
    method on_feedback_with_context (line 252) | async def on_feedback_with_context(
    method on_reaction_with_context (line 269) | async def on_reaction_with_context(
    method on_error (line 285) | async def on_error(self, error_request: ReportErrorRequest) -> None:
    method on_error_with_context (line 298) | async def on_error_with_context(
    method __post_init__ (line 317) | def __post_init__(self) -> None:
    method post_message_attachment (line 326) | async def post_message_attachment(
    method post_message_attachment (line 342) | async def post_message_attachment(
    method post_message_attachment (line 354) | async def post_message_attachment(
    method _upload_file (line 450) | async def _upload_file(
    method concat_attachment_content_to_message_body (line 471) | def concat_attachment_content_to_message_body(
    method insert_attachment_messages (line 527) | def insert_attachment_messages(self, query_request: QueryRequest) -> Q...
    method make_prompt_author_role_alternated (line 603) | def make_prompt_author_role_alternated(
    method capture_cost (line 643) | async def capture_cost(
    method authorize_cost (line 679) | async def authorize_cost(
    method _cost_requests_inner (line 715) | async def _cost_requests_inner(
    method text_event (line 751) | def text_event(text: str) -> ServerSentEvent:
    method file_event (line 755) | def file_event(
    method data_event (line 771) | def data_event(metadata: str) -> ServerSentEvent:
    method replace_response_event (line 775) | def replace_response_event(text: str) -> ServerSentEvent:
    method done_event (line 781) | def done_event() -> ServerSentEvent:
    method suggested_reply_event (line 785) | def suggested_reply_event(text: str) -> ServerSentEvent:
    method meta_event (line 789) | def meta_event(
    method error_event (line 809) | def error_event(
    method handle_report_feedback (line 827) | async def handle_report_feedback(
    method handle_report_reaction (line 833) | async def handle_report_reaction(
    method handle_report_error (line 839) | async def handle_report_error(
    method handle_settings (line 845) | async def handle_settings(
    method _yield_pending_file_events (line 851) | async def _yield_pending_file_events(
    method handle_query (line 858) | async def handle_query(
  function _find_access_key (line 931) | def _find_access_key(*, access_key: str, api_key: str) -> Optional[str]:
  function _verify_access_key (line 968) | def _verify_access_key(
  function _add_routes_for_bot (line 989) | def _add_routes_for_bot(app: FastAPI, bot: PoeBot) -> None:
  function make_app (line 1056) | def make_app(
  function run (line 1175) | def run(

FILE: src/fastapi_poe/client.py
  class AttachmentUploadError (line 49) | class AttachmentUploadError(Exception):
  class BotError (line 53) | class BotError(Exception):
  class BotErrorNoRetry (line 57) | class BotErrorNoRetry(BotError):
  class InvalidBotSettings (line 61) | class InvalidBotSettings(Exception):
  function _safe_ellipsis (line 65) | def _safe_ellipsis(obj: object, limit: int) -> str:
  class _BotContext (line 74) | class _BotContext:
    method headers (line 82) | def headers(self) -> dict[str, str]:
    method report_error (line 90) | async def report_error(
    method report_feedback (line 111) | async def report_feedback(
    method report_reaction (line 132) | async def report_reaction(
    method fetch_settings (line 153) | async def fetch_settings(self) -> SettingsResponse:
    method perform_query_request (line 162) | async def perform_query_request(
    method _get_single_json_field (line 322) | async def _get_single_json_field(
    method _get_single_json_string_field_safe (line 335) | async def _get_single_json_string_field_safe(
    method _get_single_json_integer_field_safe (line 346) | async def _get_single_json_integer_field_safe(
    method _load_json_dict (line 357) | async def _load_json_dict(
  function _default_error_handler (line 378) | def _default_error_handler(e: Exception, msg: str) -> None:
  function stream_request (line 382) | async def stream_request(
  function _get_tool_results (line 454) | async def _get_tool_results(
  function _stream_request_with_tools (line 481) | async def _stream_request_with_tools(
  function stream_request_base (line 602) | async def stream_request_base(
  function get_bot_response (line 669) | def get_bot_response(
  function get_bot_response_sync (line 739) | def get_bot_response_sync(
  function get_final_response (line 820) | async def get_final_response(
  function sync_bot_settings (line 871) | def sync_bot_settings(
  function upload_file (line 903) | async def upload_file(
  function upload_file_sync (line 1016) | def upload_file_sync(

FILE: src/fastapi_poe/sync_utils.py
  function run_sync (line 22) | def run_sync(

FILE: src/fastapi_poe/types.py
  class MessageFeedback (line 20) | class MessageFeedback(BaseModel):
  class CostItem (line 34) | class CostItem(BaseModel):
    method validate_amount_is_int (line 48) | def validate_amount_is_int(cls, v: Union[int, str, float]) -> int:
  class Attachment (line 61) | class Attachment(BaseModel):
  class MessageReaction (line 82) | class MessageReaction(BaseModel):
  class Sender (line 97) | class Sender(BaseModel):
  class User (line 115) | class User(BaseModel):
  class ProtocolMessage (line 131) | class ProtocolMessage(BaseModel):
  class RequestContext (line 171) | class RequestContext(BaseModel):
    class Config (line 172) | class Config:
  class ParametersDefinition (line 178) | class ParametersDefinition(BaseModel):
  class FunctionDefinition (line 194) | class FunctionDefinition(BaseModel):
  class ToolDefinition (line 210) | class ToolDefinition(BaseModel):
  class CustomToolDefinition (line 225) | class CustomToolDefinition(BaseModel):
  class FunctionCallDefinition (line 240) | class FunctionCallDefinition(BaseModel):
  class ToolCallDefinition (line 254) | class ToolCallDefinition(BaseModel):
  class CustomCallDefinition (line 271) | class CustomCallDefinition(BaseModel):
  class ToolResultDefinition (line 284) | class ToolResultDefinition(BaseModel):
  class FunctionCallDefinitionDelta (line 303) | class FunctionCallDefinitionDelta(BaseModel):
  class ToolCallDefinitionDelta (line 317) | class ToolCallDefinitionDelta(BaseModel):
  class BaseRequest (line 343) | class BaseRequest(BaseModel):
  class QueryRequest (line 352) | class QueryRequest(BaseRequest):
    method from_dict (line 410) | def from_dict(cls, data: dict[str, Any]) -> "QueryRequest":
  class SettingsRequest (line 436) | class SettingsRequest(BaseRequest):
  class ReportFeedbackRequest (line 445) | class ReportFeedbackRequest(BaseRequest):
  class ReportReactionRequest (line 463) | class ReportReactionRequest(BaseRequest):
  class ReportErrorRequest (line 481) | class ReportErrorRequest(BaseRequest):
  class Divider (line 498) | class Divider(BaseModel):
  class TextField (line 504) | class TextField(BaseModel):
  class TextArea (line 515) | class TextArea(BaseModel):
  class ValueNamePair (line 526) | class ValueNamePair(BaseModel):
  class DropDown (line 533) | class DropDown(BaseModel):
  class ToggleSwitch (line 544) | class ToggleSwitch(BaseModel):
  class Slider (line 554) | class Slider(BaseModel):
  class AspectRatioOption (line 567) | class AspectRatioOption(BaseModel):
  class AspectRatio (line 575) | class AspectRatio(BaseModel):
  class LiteralValue (line 591) | class LiteralValue(BaseModel):
  class ParameterValue (line 597) | class ParameterValue(BaseModel):
  class ComparatorCondition (line 603) | class ComparatorCondition(BaseModel):
  class ConditionallyRenderControls (line 611) | class ConditionallyRenderControls(BaseModel):
  class Tab (line 631) | class Tab(BaseModel):
  class Section (line 638) | class Section(BaseModel):
  class ParameterControls (line 647) | class ParameterControls(BaseModel):
  class SettingsResponse (line 654) | class SettingsResponse(BaseModel):
  class AttachmentUploadResponse (line 702) | class AttachmentUploadResponse(BaseModel):
    method from_dict (line 723) | def from_dict(cls, data: dict[str, object]) -> "AttachmentUploadRespon...
  class AttachmentHttpResponse (line 733) | class AttachmentHttpResponse(BaseModel):
  class DataResponse (line 738) | class DataResponse(BaseModel):
  class PartialResponse (line 755) | class PartialResponse(BaseModel):
    method from_dict (line 815) | def from_dict(cls, data: dict[str, object]) -> "PartialResponse":
  class ErrorResponse (line 820) | class ErrorResponse(PartialResponse):
    method from_dict (line 835) | def from_dict(cls, data: dict[str, object]) -> "ErrorResponse":
  class MetaResponse (line 847) | class MetaResponse(PartialResponse):

FILE: tests/test_base.py
  function basic_bot (line 35) | def basic_bot() -> PoeBot:
  function attachment_bot (line 59) | def attachment_bot() -> PoeBot:
  function error_bot (line 100) | def error_bot() -> PoeBot:
  function mock_request (line 114) | def mock_request() -> QueryRequest:
  function mock_request_context (line 127) | def mock_request_context() -> RequestContext:
  class TestPoeBot (line 131) | class TestPoeBot:
    method test_handle_query_basic_bot (line 134) | async def test_handle_query_basic_bot(
    method test_handle_query_error_bot (line 180) | async def test_handle_query_error_bot(
    method test_insert_attachment_messages (line 209) | def test_insert_attachment_messages(self, basic_bot: PoeBot) -> None:
    method test_make_prompt_author_role_alternated (line 331) | def test_make_prompt_author_role_alternated(self, basic_bot: PoeBot) -...
  class TestPoeBotWithAttachments (line 389) | class TestPoeBotWithAttachments:
    method test_handle_query_attachment_bot_basic (line 392) | async def test_handle_query_attachment_bot_basic(
  class TestPostMessageAttachment (line 452) | class TestPostMessageAttachment:
    method test_post_message_attachment_basic (line 456) | async def test_post_message_attachment_basic(
    method test_post_message_attachment_download_url (line 491) | async def test_post_message_attachment_download_url(
    method test_post_message_attachment_inline (line 526) | async def test_post_message_attachment_inline(
    method test_post_message_attachment_error (line 595) | async def test_post_message_attachment_error(
  class TestCostCapture (line 616) | class TestCostCapture:
    method create_sse_mock (line 618) | def create_sse_mock(
    method test_authorize_cost_success (line 642) | async def test_authorize_cost_success(
    method test_authorize_cost_failure (line 668) | async def test_authorize_cost_failure(
    method test_capture_cost_success (line 697) | async def test_capture_cost_success(
    method test_capture_cost_failure (line 723) | async def test_capture_cost_failure(
  function test_make_app (line 752) | def test_make_app(basic_bot: PoeBot, error_bot: PoeBot) -> None:

FILE: tests/test_client.py
  function mock_request (line 38) | def mock_request() -> QueryRequest:
  function message_generator (line 49) | async def message_generator() -> AsyncGenerator[BotMessage, None]:
  function mock_text_only_query_response (line 56) | async def mock_text_only_query_response() -> AsyncGenerator:
  class TestStreamRequest (line 61) | class TestStreamRequest:
    method tool_definitions_and_executables (line 64) | def tool_definitions_and_executables(
    method _create_mock_openai_response (line 143) | def _create_mock_openai_response(self, delta: dict[str, Any]) -> dict[...
    method mock_perform_query_request_for_tools (line 170) | async def mock_perform_query_request_for_tools(
    method mock_perform_query_request_for_tools_missing_first_delta_for_index (line 230) | async def mock_perform_query_request_for_tools_missing_first_delta_for...
    method mock_perform_query_request_with_no_tools_selected (line 282) | async def mock_perform_query_request_with_no_tools_selected(
    method mock_perform_query_request_with_bot_returning_regular_response (line 305) | async def mock_perform_query_request_with_bot_returning_regular_response(
    method test_stream_request_basic (line 316) | async def test_stream_request_basic(
    method test_stream_request_with_extra_headers (line 329) | async def test_stream_request_with_extra_headers(
    method test_stream_request_with_tools (line 346) | async def test_stream_request_with_tools(
    method test_stream_request_with_tools_when_no_tools_selected (line 398) | async def test_stream_request_with_tools_when_no_tools_selected(
    method test_stream_request_with_tools_with_bot_returning_regular_response (line 417) | async def test_stream_request_with_tools_with_bot_returning_regular_re...
    method test_stream_request_with_tools_and_tool_executables (line 436) | async def test_stream_request_with_tools_and_tool_executables(
    method test_stream_request_with_tools_and_tool_executables_missing_first_delta_for_index (line 497) | async def test_stream_request_with_tools_and_tool_executables_missing_...
    method test_stream_request_with_tools_and_tool_executables_when_no_tools_selected (line 543) | async def test_stream_request_with_tools_and_tool_executables_when_no_...
    method test_stream_request_with_tools_index_preserved (line 564) | async def test_stream_request_with_tools_index_preserved(
  class Test_BotContext (line 606) | class Test_BotContext:
    method mock_bot_context (line 609) | def mock_bot_context(self) -> _BotContext:
    method create_sse_mock (line 617) | def create_sse_mock(
    method test_headers_include_accept_header_by_default (line 634) | def test_headers_include_accept_header_by_default(self) -> None:
    method test_headers_include_api_key_as_auth_header (line 639) | def test_headers_include_api_key_as_auth_header(self) -> None:
    method test_headers_include_extra_headers (line 647) | def test_headers_include_extra_headers(self) -> None:
    method test_headers_extra_headers_override_default_headers (line 660) | def test_headers_extra_headers_override_default_headers(self) -> None:
    method test_perform_query_request_text_events (line 672) | async def test_perform_query_request_text_events(
    method test_perform_query_request_non_text_events (line 692) | async def test_perform_query_request_non_text_events(
    method test_perform_query_request_no_done_event_still_succeeds (line 767) | async def test_perform_query_request_no_done_event_still_succeeds(
    method test_perform_query_request_error_with_allow_retry_false (line 783) | async def test_perform_query_request_error_with_allow_retry_false(
    method test_perform_query_request_error_with_allow_retry_true (line 798) | async def test_perform_query_request_error_with_allow_retry_true(
    method test_perform_query_request_text_events_with_index (line 813) | async def test_perform_query_request_text_events_with_index(
    method test_perform_query_request_non_text_events_with_index (line 880) | async def test_perform_query_request_non_text_events_with_index(
    method test_perform_query_request_with_error (line 993) | async def test_perform_query_request_with_error(
  function test_get_final_response (line 1013) | async def test_get_final_response(
  function test_get_bot_response (line 1025) | async def test_get_bot_response(
  function test_get_bot_response_sync (line 1050) | def test_get_bot_response_sync(
  function test_get_bot_response_with_adopt_current_bot_name (line 1076) | async def test_get_bot_response_with_adopt_current_bot_name(
  function test__safe_ellipsis (line 1106) | def test__safe_ellipsis(test_input: object, limit: int, expected: str) -...
  function test_sync_bot_settings (line 1112) | def test_sync_bot_settings(mock_httpx_post: Mock) -> None:
  function _make_mock_async_client (line 1139) | def _make_mock_async_client(
  function test_upload_file_via_url (line 1161) | async def test_upload_file_via_url() -> None:
  function test_upload_file_raw_bytes (line 1196) | async def test_upload_file_raw_bytes() -> None:
  function test_upload_file_error_raises (line 1228) | async def test_upload_file_error_raises() -> None:
  function test_upload_file_with_extra_headers (line 1240) | async def test_upload_file_with_extra_headers() -> None:

FILE: tests/test_sync_utils.py
  function _add (line 7) | async def _add(a: int, b: int) -> int:
  function test_run_sync_without_event_loop (line 12) | def test_run_sync_without_event_loop() -> None:
  function test_run_sync_inside_event_loop (line 17) | async def test_run_sync_inside_event_loop() -> None:
  function test_run_sync_rejects_session_inside_loop (line 22) | async def test_run_sync_rejects_session_inside_loop() -> None:
  function test_run_sync_propagates_exceptions (line 27) | def test_run_sync_propagates_exceptions() -> None:

FILE: tests/test_types.py
  class TestSettingsResponse (line 17) | class TestSettingsResponse:
    method test_default_response_version (line 19) | def test_default_response_version(self) -> None:
  function test_extra_attrs (line 24) | def test_extra_attrs() -> None:
  function test_cost_item (line 33) | def test_cost_item() -> None:
  class TestSender (line 46) | class TestSender:
    method test_sender_basic (line 48) | def test_sender_basic(self) -> None:
    method test_sender_with_id (line 53) | def test_sender_with_id(self) -> None:
    method test_sender_with_name (line 58) | def test_sender_with_name(self) -> None:
    method test_sender_with_all_fields (line 63) | def test_sender_with_all_fields(self) -> None:
  class TestUser (line 69) | class TestUser:
    method test_user_basic (line 71) | def test_user_basic(self) -> None:
    method test_user_with_name (line 76) | def test_user_with_name(self) -> None:
    method test_user_requires_id (line 81) | def test_user_requires_id(self) -> None:
  class TestMessageReaction (line 86) | class TestMessageReaction:
    method test_reaction_basic (line 88) | def test_reaction_basic(self) -> None:
    method test_reaction_requires_user_id (line 93) | def test_reaction_requires_user_id(self) -> None:
    method test_reaction_requires_reaction (line 97) | def test_reaction_requires_reaction(self) -> None:
  class TestProtocolMessage (line 102) | class TestProtocolMessage:
    method test_protocol_message_basic (line 104) | def test_protocol_message_basic(self) -> None:
    method test_protocol_message_with_reactions (line 112) | def test_protocol_message_with_reactions(self) -> None:
    method test_protocol_message_with_referenced_message (line 128) | def test_protocol_message_with_referenced_message(self) -> None:
    method test_protocol_message_optional_sender (line 145) | def test_protocol_message_optional_sender(self) -> None:
    method test_protocol_message_nested_referenced_message (line 152) | def test_protocol_message_nested_referenced_message(self) -> None:
    method test_protocol_message_with_sender_object (line 176) | def test_protocol_message_with_sender_object(self) -> None:
  class TestQueryRequest (line 187) | class TestQueryRequest:
    method test_query_request_with_users (line 189) | def test_query_request_with_users(self) -> None:
    method test_query_request_empty_users (line 205) | def test_query_request_empty_users(self) -> None:
    method test_query_request_with_reactions_in_messages (line 216) | def test_query_request_with_reactions_in_messages(self) -> None:
  class TestCustomToolDefinition (line 236) | class TestCustomToolDefinition:
    method test_basic_instantiation (line 238) | def test_basic_instantiation(self) -> None:
    method test_field_name_works_with_populate_by_name (line 249) | def test_field_name_works_with_populate_by_name(self) -> None:
    method test_requires_name_field (line 258) | def test_requires_name_field(self) -> None:
    method test_optional_fields (line 263) | def test_optional_fields(self) -> None:
    method test_with_only_name_and_description (line 270) | def test_with_only_name_and_description(self) -> None:
    method test_with_only_name_and_format (line 277) | def test_with_only_name_and_format(self) -> None:
    method test_serialization_uses_alias (line 284) | def test_serialization_uses_alias(self) -> None:
    method test_serialization_without_alias (line 294) | def test_serialization_without_alias(self) -> None:
    method test_json_serialization (line 304) | def test_json_serialization(self) -> None:
    method test_deserialization_from_json (line 313) | def test_deserialization_from_json(self) -> None:
    method test_invalid_type_for_format (line 324) | def test_invalid_type_for_format(self) -> None:
  class TestCustomCallDefinition (line 330) | class TestCustomCallDefinition:
    method test_basic_instantiation (line 332) | def test_basic_instantiation(self) -> None:
    method test_field_name_works_with_populate_by_name (line 338) | def test_field_name_works_with_populate_by_name(self) -> None:
    method test_requires_all_fields (line 343) | def test_requires_all_fields(self) -> None:
    method test_serialization_uses_alias (line 351) | def test_serialization_uses_alias(self) -> None:
    method test_serialization_without_alias (line 359) | def test_serialization_without_alias(self) -> None:
    method test_json_serialization (line 367) | def test_json_serialization(self) -> None:
    method test_deserialization_from_json (line 374) | def test_deserialization_from_json(self) -> None:
Condensed preview — 26 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (275K chars).
[
  {
    "path": ".flake8",
    "chars": 105,
    "preview": "[flake8]\nmax-line-length = 100\nextend-immutable-calls = Depends, fastapi.Depends, fastapi.params.Depends\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "chars": 224,
    "preview": "# This file is used to automatically assign reviewers to PRs\n# For more information see: https://help.github.com/en/gith"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 359,
    "preview": "## Description\n\nBrief description of changes\n\n## Changes Made\n\n- Detailed change 1\n- Detailed change 2\n\n## Testing Done\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "chars": 1287,
    "preview": "name: Lint\n\non: [push, pull_request]\n\njobs:\n  precommit:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/ch"
  },
  {
    "path": ".github/workflows/publish.yml",
    "chars": 1386,
    "preview": "# Based on\n# https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-wor"
  },
  {
    "path": ".gitignore",
    "chars": 39,
    "preview": "__pycache__/\n.DS_Store\n.coverage\nvenv/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 503,
    "preview": "repos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.2.2\n    hooks:\n      - id: ruff\n        args: "
  },
  {
    "path": ".prettierignore",
    "chars": 10,
    "preview": "docs/*.md\n"
  },
  {
    "path": ".prettierrc.yaml",
    "chars": 49,
    "preview": "proseWrap: always\nprintWidth: 88\nendOfLine: auto\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 1267,
    "preview": "# General\n\n- All changes should be made through pull requests\n- Pull requests should only be merged once all checks pass"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "                                 Apache License\n                           Version 2.0, January 2004\n                   "
  },
  {
    "path": "README.md",
    "chars": 1512,
    "preview": "# fastapi_poe\n\nAn implementation of the\n[Poe protocol](https://creator.poe.com/docs/poe-protocol-specification) using Fa"
  },
  {
    "path": "docs/api_reference.md",
    "chars": 24929,
    "preview": "\n\nThe following is the API reference for the [fastapi_poe](https://github.com/poe-platform/fastapi_poe) client library. "
  },
  {
    "path": "docs/generate_api_reference.py",
    "chars": 4358,
    "preview": "\"\"\"\n\n- To generate reference documentation:\n  - Add/update docstrings in the codebase. If you are adding a new class/fun"
  },
  {
    "path": "pyproject.toml",
    "chars": 2551,
    "preview": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"fastapi_poe\"\nversion = \"0.0"
  },
  {
    "path": "src/fastapi_poe/__init__.py",
    "chars": 2283,
    "preview": "__all__ = [\n    \"PoeBot\",\n    \"run\",\n    \"make_app\",\n    \"stream_request\",\n    \"get_bot_response\",\n    \"get_bot_response"
  },
  {
    "path": "src/fastapi_poe/base.py",
    "chars": 46157,
    "preview": "import argparse\nimport asyncio\nimport copy\nimport json\nimport logging\nimport os\nimport random\nimport string\nimport sys\ni"
  },
  {
    "path": "src/fastapi_poe/client.py",
    "chars": 40815,
    "preview": "\"\"\"\n\nClient for talking to other Poe bots through the Poe bot query API.\nFor more details, see: https://creator.poe.com/"
  },
  {
    "path": "src/fastapi_poe/py.typed",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/fastapi_poe/sync_utils.py",
    "chars": 2455,
    "preview": "\"\"\"\nUtility helpers for running async functions from synchronous code.\n\n1. If there is no running event loop, just `asyn"
  },
  {
    "path": "src/fastapi_poe/templates.py",
    "chars": 895,
    "preview": "\"\"\"\n\nThis module contains a collection of string templates designed to streamline the integration\nof features like attac"
  },
  {
    "path": "src/fastapi_poe/types.py",
    "chars": 27224,
    "preview": "import math\nfrom typing import Any, Optional, Union, cast, get_args\n\nfrom fastapi import Request\nfrom pydantic import Ba"
  },
  {
    "path": "tests/test_base.py",
    "chars": 28016,
    "preview": "import json\nfrom collections.abc import AsyncIterable, AsyncIterator\nfrom contextlib import AbstractAsyncContextManager,"
  },
  {
    "path": "tests/test_client.py",
    "chars": 49531,
    "preview": "import json\nfrom collections.abc import AsyncGenerator, AsyncIterator, Awaitable\nfrom contextlib import AbstractAsyncCon"
  },
  {
    "path": "tests/test_sync_utils.py",
    "chars": 732,
    "preview": "import asyncio\n\nimport pytest\nfrom fastapi_poe.sync_utils import run_sync\n\n\nasync def _add(a: int, b: int) -> int:\n    a"
  },
  {
    "path": "tests/test_types.py",
    "chars": 13956,
    "preview": "import pydantic\nimport pytest\nfrom fastapi_poe.types import (\n    CostItem,\n    CustomCallDefinition,\n    CustomToolDefi"
  }
]

About this extraction

This page contains the full source code of the poe-platform/fastapi_poe GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 26 files (255.9 KB), approximately 56.8k tokens, and a symbol index with 256 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!