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,
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
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.