diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index be4fe4b..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-# To get started with Dependabot version updates, you'll need to specify which
-# package ecosystems to update and where the package manifests are located.
-# Please see the documentation for all configuration options:
-# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
-
-version: 2
-updates:
- - package-ecosystem: "uv"
- directory: "/"
- schedule:
- interval: "daily"
diff --git a/.github/pr-title-checker-config.json b/.github/pr-title-checker-config.json
deleted file mode 100644
index 2cb5ec6..0000000
--- a/.github/pr-title-checker-config.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "LABEL": {
- "name": "Invalid PR Title",
- "color": "B60205"
- },
- "CHECKS": {
- "regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*"
- },
- "MESSAGES": {
- "failure": "The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention."
- }
-}
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index db21694..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: CI
-
-on:
- pull_request:
- push:
- branches:
- - main
-
-jobs:
- test:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: ["3.12", "3.13", "3.14"]
- steps:
- - name: Set up Python ${{ matrix.python-version }}
- id: setup-python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
- allow-prereleases: true
-
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up uv
- uses: astral-sh/setup-uv@v1
- with:
- version: "0.8.5"
-
- - name: Install dependencies
- env:
- UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}
- run: uv sync --frozen --all-extras
-
- - name: Run checks
- env:
- UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}
- run: make check
-
- - name: Run tests
- env:
- UV_PYTHON: ${{ steps.setup-python.outputs.python-path }}
- run: make test
-
- docs:
- runs-on: ubuntu-latest
- env:
- FOOTER_VERSION: ${{ github.ref_name }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up Python 3.12
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- allow-prereleases: true
-
- - name: Set up uv
- uses: astral-sh/setup-uv@v1
- with:
- version: "0.8.5"
-
- - name: Install dependencies
- run: uv sync --frozen --all-extras
-
- - name: Generate API documentation
- run: uv run pdoc kosong --docformat google --footer-text "kosong ${FOOTER_VERSION}" -o docs
-
- - name: Upload docs preview
- uses: actions/upload-artifact@v4
- with:
- name: docs-preview
- path: docs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 3c00ad9..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,92 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - "*"
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: false
-
-jobs:
- publish:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up Python 3.12
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- allow-prereleases: true
-
- - name: Set up uv
- uses: astral-sh/setup-uv@v1
- with:
- version: "0.8.5"
-
- - name: Build distributions
- run: uv build
-
- - name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- user: __token__
- password: ${{ secrets.PYPI_API_TOKEN }}
- packages-dir: dist
-
- build-docs:
- runs-on: ubuntu-latest
- env:
- FOOTER_VERSION: ${{ github.ref_name }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up Python 3.12
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- allow-prereleases: true
-
- - name: Set up uv
- uses: astral-sh/setup-uv@v1
- with:
- version: "0.8.5"
-
- - name: Install dependencies
- run: uv sync --frozen --all-extras
-
- - name: Configure GitHub Pages
- uses: actions/configure-pages@v4
-
- - name: Generate API documentation
- run: uv run pdoc kosong --docformat google --footer-text "kosong ${FOOTER_VERSION}" -o docs
-
- - name: Disable Jekyll processing
- run: touch docs/.nojekyll
-
- - name: Upload documentation artifact
- uses: actions/upload-pages-artifact@v3
- with:
- path: docs
-
- deploy-docs:
- needs:
- - publish
- - build-docs
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
diff --git a/.github/workflows/translator.yml b/.github/workflows/translator.yml
deleted file mode 100644
index 86fee88..0000000
--- a/.github/workflows/translator.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Translator
-on:
- issues:
- types: [opened, edited]
- issue_comment:
- types: [created, edited]
- discussion:
- types: [created, edited]
- discussion_comment:
- types: [created, edited]
- pull_request_target:
- types: [opened, edited]
- pull_request_review_comment:
- types: [created, edited]
-
-jobs:
- translate:
- permissions:
- issues: write
- discussions: write
- pull-requests: write
- runs-on: ubuntu-latest
- steps:
- - uses: lizheming/github-translate-action@1.1.2
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- IS_MODIFY_TITLE: true
- APPEND_TRANSLATION: true
diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml
deleted file mode 100644
index 9126edd..0000000
--- a/.github/workflows/typos.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: Typo Checker
-on: [pull_request]
-
-jobs:
- run:
- name: Spell Check with Typos
- runs-on: ubuntu-latest
- steps:
- - name: Checkout Actions Repository
- uses: actions/checkout@v4
-
- - name: Check spelling of the entire repository
- uses: crate-ci/typos@v1.38.1
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 41f1422..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,21 +0,0 @@
-# Python-generated files
-__pycache__/
-*.py[oc]
-build/
-dist/
-wheels/
-*.egg-info
-
-# Virtual environments
-.venv
-
-# Project files
-.vscode
-.env
-.env.local
-/tests_local
-/tests/*.jsonl
-.idea
-
-# Local generated files
-/docs/
diff --git a/.python-version b/.python-version
deleted file mode 100644
index e4fba21..0000000
--- a/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-3.12
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 1e766ce..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# Changelog
-
-## [Unreleased]
-
-
-## [0.35.0] - 2025-12-24
-
-- Add registry-based `DisplayBlock` validation to allow custom tool/UI display block subclasses, plus `BriefDisplayBlock` and `UnknownDisplayBlock`
-- Rename brief display payload field to `text` and keep tool return display blocks empty when no brief is provided
-
-## [0.34.1] - 2025-12-22
-
-- Add `convert_mcp_content` util to convert MCP content type to kosong content type
-
-## [0.34.0] - 2025-12-19
-
-- Support Vertex AI in GoogleGenAI chat provider
-- Add `SimpleToolset.add()` and `SimpleToolset.remove()` methods to add or remove tools from the toolset
-
-## [0.33.0] - 2025-12-12
-
-- Lower the required Python version to 3.12
-- Make the `contrib` module an optional extra that can be installed with `uv add "kosong[contrib]"`
-
-## [0.32.0] - 2025-12-08
-
-- Introduce `ToolMessageConversion` to customize how tool messages are converted in chat providers
-
-## [0.31.0] - 2025-12-03
-
-- Fix OpenAI Responses provider not mapping `role="system"` to `developer`
-- Improve the compatibility of OpenAI Responses and Anthropic providers against some third-party APIs
-
-## [0.30.0] - 2025-12-03
-
-- Serialize empty content as an empty list instead of `None`
-- Fix Kimi chat provider panicking when `stream` is `False`
-
-## [0.29.0] - 2025-12-02
-
-- Change `Message.content` field from `str | list[ContentPart]` to just `list[ContentPart]`
-- Add `Message.extract_text()` method to extract text content from message
-
-## [0.28.1] - 2025-12-01
-
-- Fix interleaved thinking for Kimi and OpenAILegacy chat providers
-
-## [0.28.0] - 2025-11-28
-
-- Support non-OpenAI models which do not accept `developer` role in system prompt in `OpenAIResponses` chat provider
-- Fix token usage for Anthropic chat provider
-- Fix `StepResult.tool_results()` cannot be called multiple times
-- Add `EchoChatProvider` to allow generate assistant responses by echoing back the user messages
-
-## [0.27.1] - 2025-11-24
-
-- Nothing
-
-## [0.27.0] - 2025-11-24
-
-- Fix function call ID in `GoogleGenAI` chat provider
-- Make `CallableTool2` not a `pydantic.BaseModel`
-- Introduce `ToolReturnValue` as the common base class of `ToolOk` and `ToolError`
-- Require `CallableTool` and `CallableTool2` to return `ToolReturnValue` instead of `ToolOk | ToolError`
-- Rename `ToolResult.result` to `ToolResult.return_value`
-
-## [0.26.2] - 2025-11-20
-
-- Better thinking level mapping in `GoogleGenAI` chat provider
-
-## [0.26.1] - 2025-11-19
-
-- Deref JSON schema in tool parameters to fix compatibility with some LLM providers
-
-## [0.26.0] - 2025-11-19
-
-- Fix thinking part in `Anthropic` provider's non-stream mode
-- Add `GoogleGenAI` chat provider
-
-## [0.25.1] - 2025-11-18
-
-- Catch httpx exceptions correctly in Kimi and OpenAI providers
-
-## [0.25.0] - 2025-11-13
-
-- Add `reasoning_key` argument to `OpenAILegacy` chat provider to specify the field for reasoning content in messages
-
-## [0.24.0] - 2025-11-12
-
-- Set default temperature settings for Kimi models based on model name
-
-## [0.23.0] - 2025-11-10
-
-- Change type of `ToolError.output` to `str | ContentPart | Sequence[ContentPart]`
-
-## [0.22.0] - 2025-11-10
-
-- Add `APIEmptyResponseError` for cases where the API returns an empty response
-- Add `GenerateResult` as the return type of `generate` function
-- Add `id: str | None` field to `GenerateResult` and `StepResult`
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 7a4a3ea..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,202 +0,0 @@
-
- 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.
\ No newline at end of file
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 4bc7a5c..0000000
--- a/Makefile
+++ /dev/null
@@ -1,13 +0,0 @@
-.PHONY: format check test
-
-format:
- uv run ruff check --fix
- uv run ruff format
-
-check:
- uv run ruff check
- uv run ruff format --check
- uv run pyright
-
-test:
- uv run pytest --doctest-modules -vv
diff --git a/NOTICE b/NOTICE
deleted file mode 100644
index b26fc57..0000000
--- a/NOTICE
+++ /dev/null
@@ -1,5 +0,0 @@
-Kosong
-Copyright 2025 Moonshot AI
-
-This product includes software developed at
-Moonshot AI (https://www.moonshot.ai/).
\ No newline at end of file
diff --git a/README.md b/README.md
index 5a4eb24..8c7fb21 100644
--- a/README.md
+++ b/README.md
@@ -1,183 +1,4 @@
# Kosong
-Kosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in.
-
-> Kosong means "empty" in Malay and Indonesian.
-
-## Installation
-
-Kosong requires Python 3.12 or higher. We recommend using uv as the package manager.
-
-Init your project with:
-
-```bash
-uv init --python 3.12 # or higher
-```
-
-Then add Kosong as a dependency:
-
-```bash
-uv add kosong
-```
-
-To enable chat providers other than Kimi (e.g. Anthropic and Google Gemini), install the optional extra:
-
-```bash
-uv add 'kosong[contrib]'
-```
-
-## Examples
-
-### Simple chat completion
-
-```python
-import asyncio
-
-import kosong
-from kosong.chat_provider.kimi import Kimi
-from kosong.message import Message
-
-
-async def main() -> None:
- kimi = Kimi(
- base_url="https://api.moonshot.ai/v1",
- api_key="your_kimi_api_key_here",
- model="kimi-k2-turbo-preview",
- )
-
- history = [
- Message(role="user", content="Who are you?"),
- ]
-
- result = await kosong.generate(
- chat_provider=kimi,
- system_prompt="You are a helpful assistant.",
- tools=[],
- history=history,
- )
- print(result.message)
- print(result.usage)
-
-
-asyncio.run(main())
-```
-
-### Streaming output
-
-```python
-import asyncio
-
-import kosong
-from kosong.chat_provider import StreamedMessagePart
-from kosong.chat_provider.kimi import Kimi
-from kosong.message import Message
-
-
-async def main() -> None:
- kimi = Kimi(
- base_url="https://api.moonshot.ai/v1",
- api_key="your_kimi_api_key_here",
- model="kimi-k2-turbo-preview",
- )
-
- history = [
- Message(role="user", content="Who are you?"),
- ]
-
- def output(message_part: StreamedMessagePart):
- print(message_part)
-
- result = await kosong.generate(
- chat_provider=kimi,
- system_prompt="You are a helpful assistant.",
- tools=[],
- history=history,
- on_message_part=output,
- )
- print(result.message)
- print(result.usage)
-
-
-asyncio.run(main())
-```
-
-### Tool calling with `kosong.step`
-
-```python
-import asyncio
-
-from pydantic import BaseModel
-
-import kosong
-from kosong import StepResult
-from kosong.chat_provider.kimi import Kimi
-from kosong.message import Message
-from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue
-from kosong.tooling.simple import SimpleToolset
-
-
-class AddToolParams(BaseModel):
- a: int
- b: int
-
-
-class AddTool(CallableTool2[AddToolParams]):
- name: str = "add"
- description: str = "Add two integers."
- params: type[AddToolParams] = AddToolParams
-
- async def __call__(self, params: AddToolParams) -> ToolReturnValue:
- return ToolOk(output=str(params.a + params.b))
-
-
-async def main() -> None:
- kimi = Kimi(
- base_url="https://api.moonshot.ai/v1",
- api_key="your_kimi_api_key_here",
- model="kimi-k2-turbo-preview",
- )
-
- toolset = SimpleToolset()
- toolset += AddTool()
-
- history = [
- Message(role="user", content="Please add 2 and 3 with the add tool."),
- ]
-
- result: StepResult = await kosong.step(
- chat_provider=kimi,
- system_prompt="You are a precise math tutor.",
- toolset=toolset,
- history=history,
- )
- print(result.message)
- print(await result.tool_results())
-
-
-asyncio.run(main())
-```
-
-## Builtin Demo
-
-Kosong comes with a builtin demo agent that you can run locally. To start the demo, run:
-
-```sh
-export KIMI_BASE_URL="https://api.moonshot.ai/v1"
-export KIMI_API_KEY="your_kimi_api_key"
-
-uv run python -m kosong kimi --with-bash
-```
-
-## Development
-
-To set up a development environment, clone the repository and install the dependencies:
-
-```bash
-git clone https://github.com/MoonshotAI/kosong.git
-cd kosong
-uv sync --all-extras
-
-make check # run lint and type checks
-make test # run tests
-make format # format code
-```
+The development of this package has moved to the kimi-cli monorepo:
+https://github.com/MoonshotAI/kimi-cli/tree/main/packages/kosong.
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 293df3a..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,57 +0,0 @@
-[project]
-name = "kosong"
-version = "0.35.0"
-description = "The LLM abstraction layer for modern AI agent applications."
-readme = "README.md"
-requires-python = ">=3.12"
-dependencies = [
- "anthropic>=0.75.0",
- "google-genai>=1.56.0",
- "jsonschema>=4.25.1",
- "loguru>=0.7.3",
- "openai>=2.14.0,<2.15.0",
- "pydantic>=2.12.5",
- "python-dotenv>=1.2.1",
- "typing-extensions>=4.15.0",
- "mcp>=1,<2"
-]
-
-[project.optional-dependencies]
-contrib = [
- "anthropic>=0.75.0",
- "google-genai>=1.55.0",
-]
-
-[dependency-groups]
-dev = [
- "pyright>=1.1.407",
- "pytest>=9.0.2",
- "pytest-asyncio>=1.3.0",
- "respx>=0.22.0",
- "ruff>=0.14.10",
- "inline-snapshot[black]>=0.31.1",
- "pdoc>=16.0.0",
-]
-
-[build-system]
-requires = ["uv_build>=0.8.5,<0.10.0"]
-build-backend = "uv_build"
-
-[tool.uv.build-backend]
-module-name = ["kosong"]
-
-[tool.ruff]
-line-length = 100
-
-[tool.ruff.format]
-docstring-code-format = true
-
-[tool.ruff.lint]
-select = [
- "E", # pycodestyle
- "F", # Pyflakes
- "UP", # pyupgrade
- "B", # flake8-bugbear
- "SIM", # flake8-simplify
- "I", # isort
-]
diff --git a/pyrightconfig.json b/pyrightconfig.json
deleted file mode 100644
index b40ed3e..0000000
--- a/pyrightconfig.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "typeCheckingMode": "strict",
- "pythonVersion": "3.12",
- "include": [
- "src/**/*.py",
- "tests/**/*.py"
- ],
- "exclude": [
- "**/__pycache__/**/*.py",
- ]
-}
\ No newline at end of file
diff --git a/src/kosong/__init__.py b/src/kosong/__init__.py
deleted file mode 100644
index 7dfdd94..0000000
--- a/src/kosong/__init__.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""
-Kosong is an LLM abstraction layer designed for modern AI agent applications.
-It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you
-can build agents with ease and avoid vendor lock-in.
-
-Key features:
-
-- `kosong.generate` creates a completion stream and merges streamed message parts (including
- content and tool calls) from any `ChatProvider` into a complete `Message` plus optional
- `TokenUsage`.
-- `kosong.step` layers tool dispatch (`Tool`, `Toolset`, `SimpleToolset`) over `generate`,
- exposing `StepResult` with awaited tool outputs and streaming callbacks.
-- Message structures and tool abstractions live under `kosong.message` and `kosong.tooling`.
-
-Example:
-
-```python
-import asyncio
-
-from pydantic import BaseModel
-
-import kosong
-from kosong import StepResult
-from kosong.chat_provider.kimi import Kimi
-from kosong.message import Message
-from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue
-from kosong.tooling.simple import SimpleToolset
-
-
-class AddToolParams(BaseModel):
- a: int
- b: int
-
-
-class AddTool(CallableTool2[AddToolParams]):
- name: str = "add"
- description: str = "Add two integers."
- params: type[AddToolParams] = AddToolParams
-
- async def __call__(self, params: AddToolParams) -> ToolReturnValue:
- return ToolOk(output=str(params.a + params.b))
-
-
-async def main() -> None:
- kimi = Kimi(
- base_url="https://api.moonshot.ai/v1",
- api_key="your_kimi_api_key_here",
- model="kimi-k2-turbo-preview",
- )
-
- toolset = SimpleToolset()
- toolset += AddTool()
-
- history = [
- Message(role="user", content="Please add 2 and 3 with the add tool."),
- ]
-
- result: StepResult = await kosong.step(
- chat_provider=kimi,
- system_prompt="You are a precise math tutor.",
- toolset=toolset,
- history=history,
- )
- print(result.message)
- print(await result.tool_results())
-
-
-asyncio.run(main())
-```
-"""
-
-import asyncio
-from collections.abc import Callable, Sequence
-from dataclasses import dataclass
-
-from loguru import logger
-
-from kosong._generate import GenerateResult, generate
-from kosong.chat_provider import ChatProvider, ChatProviderError, StreamedMessagePart, TokenUsage
-from kosong.message import Message, ToolCall
-from kosong.tooling import ToolResult, ToolResultFuture, Toolset
-from kosong.utils.aio import Callback
-
-# Explicitly import submodules
-from . import chat_provider, contrib, message, tooling, utils
-
-logger.disable("kosong")
-
-__all__ = [
- # submodules
- "chat_provider",
- "tooling",
- "message",
- "utils",
- "contrib",
- # classes and functions
- "generate",
- "GenerateResult",
- "step",
- "StepResult",
-]
-
-
-async def step(
- chat_provider: ChatProvider,
- system_prompt: str,
- toolset: Toolset,
- history: Sequence[Message],
- *,
- on_message_part: Callback[[StreamedMessagePart], None] | None = None,
- on_tool_result: Callable[[ToolResult], None] | None = None,
-) -> "StepResult":
- """
- Run one agent "step". In one step, the function generates LLM response based on the given
- context for exactly one time. All new message parts will be streamed to `on_message_part` in
- real-time if provided. Tool calls will be handled by `toolset`. The generated message will be
- returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be
- handled asynchronously and the results need to be fetched with `await result.tool_results()`.
-
- The message history will NOT be modified in this function.
-
- The token usage will be returned in the `StepResult` if available.
-
- Raises:
- APIConnectionError: If the API connection fails.
- APITimeoutError: If the API request times out.
- APIStatusError: If the API returns a status code of 4xx or 5xx.
- APIEmptyResponseError: If the API returns an empty response.
- ChatProviderError: If any other recognized chat provider error occurs.
- asyncio.CancelledError: If the step is cancelled.
- """
-
- tool_calls: list[ToolCall] = []
- tool_result_futures: dict[str, ToolResultFuture] = {}
-
- def future_done_callback(future: ToolResultFuture):
- if on_tool_result:
- try:
- result = future.result()
- on_tool_result(result)
- except asyncio.CancelledError:
- return
-
- async def on_tool_call(tool_call: ToolCall):
- tool_calls.append(tool_call)
- result = toolset.handle(tool_call)
-
- if isinstance(result, ToolResult):
- future = ToolResultFuture()
- future.add_done_callback(future_done_callback)
- future.set_result(result)
- tool_result_futures[tool_call.id] = future
- else:
- result.add_done_callback(future_done_callback)
- tool_result_futures[tool_call.id] = result
-
- try:
- result = await generate(
- chat_provider,
- system_prompt,
- toolset.tools,
- history,
- on_message_part=on_message_part,
- on_tool_call=on_tool_call,
- )
- except (ChatProviderError, asyncio.CancelledError):
- # cancel all the futures to avoid hanging tasks
- for future in tool_result_futures.values():
- future.remove_done_callback(future_done_callback)
- future.cancel()
- await asyncio.gather(*tool_result_futures.values(), return_exceptions=True)
- raise
-
- return StepResult(
- result.id,
- result.message,
- result.usage,
- tool_calls,
- tool_result_futures,
- )
-
-
-@dataclass(frozen=True, slots=True)
-class StepResult:
- id: str | None
- """The ID of the generated message."""
-
- message: Message
- """The message generated in this step."""
-
- usage: TokenUsage | None
- """The token usage in this step."""
-
- tool_calls: list[ToolCall]
- """All the tool calls generated in this step."""
-
- _tool_result_futures: dict[str, ToolResultFuture]
- """@private The futures of the results of the spawned tool calls."""
-
- async def tool_results(self) -> list[ToolResult]:
- """All the tool results returned by corresponding tool calls."""
- if not self._tool_result_futures:
- return []
-
- try:
- results: list[ToolResult] = []
- for tool_call in self.tool_calls:
- future = self._tool_result_futures[tool_call.id]
- result = await future
- results.append(result)
- return results
- finally:
- # one exception should cancel all the futures to avoid hanging tasks
- for future in self._tool_result_futures.values():
- future.cancel()
- await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)
diff --git a/src/kosong/__main__.py b/src/kosong/__main__.py
deleted file mode 100644
index 75cac74..0000000
--- a/src/kosong/__main__.py
+++ /dev/null
@@ -1,164 +0,0 @@
-import asyncio
-import os
-import textwrap
-from argparse import ArgumentParser
-from typing import Literal
-
-from dotenv import load_dotenv
-from pydantic import BaseModel
-
-import kosong
-from kosong.chat_provider import ChatProvider
-from kosong.message import Message
-from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolResult, ToolReturnValue, Toolset
-from kosong.tooling.simple import SimpleToolset
-
-
-class BashToolParams(BaseModel):
- command: str
- """The bash command to execute."""
-
-
-class BashTool(CallableTool2[BashToolParams]):
- name: str = "Bash"
- description: str = "Execute a bash command."
- params: type[BashToolParams] = BashToolParams
-
- async def __call__(self, params: BashToolParams) -> ToolReturnValue:
- proc = await asyncio.create_subprocess_shell(
- params.command,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, stderr = await proc.communicate()
- stdout_text = stdout.decode().strip()
- stderr_text = stderr.decode().strip()
- output_text = "\n".join(filter(None, [stdout_text, stderr_text]))
- if proc.returncode == 0:
- return ToolOk(output=output_text)
- else:
- return ToolError(
- output=output_text,
- message=f"Command failed with exit code {proc.returncode}",
- brief="Bash command failed.",
- )
-
-
-async def agent_loop(chat_provider: ChatProvider, toolset: Toolset):
- system_prompt = "You are a helpful assistant."
- history: list[Message] = []
-
- while True:
- user_input = input("You: ").strip()
- if not user_input:
- continue
- if user_input.lower() in {"exit", "quit"}:
- break
-
- history.append(Message(role="user", content=user_input))
-
- while True:
- result = await kosong.step(
- chat_provider=chat_provider,
- system_prompt=system_prompt,
- toolset=toolset,
- history=history,
- )
-
- tool_results = await result.tool_results()
-
- assistant_message = result.message
- tool_messages = [tool_result_to_message(tr) for tr in tool_results]
-
- history.append(assistant_message)
- history.extend(tool_messages)
-
- if s := assistant_message.extract_text():
- print("Assistant:\n", textwrap.indent(s, " "))
- for tool_msg in tool_messages:
- if s := tool_msg.extract_text():
- print("Tool:\n", textwrap.indent(s, " "))
-
- if not result.tool_calls:
- break
-
-
-def tool_result_to_message(result: ToolResult) -> Message:
- return Message(
- role="tool",
- tool_call_id=result.tool_call_id,
- content=result.return_value.output,
- )
-
-
-async def main():
- load_dotenv()
-
- parser = ArgumentParser(description="A simple agent.")
- parser.add_argument(
- "provider",
- choices=["kimi", "openai", "anthropic", "google"],
- help="The chat provider to use.",
- )
- parser.add_argument(
- "--with-bash",
- action="store_true",
- help="Enable Bash tool.",
- )
- args = parser.parse_args()
-
- provider: Literal["kimi", "openai", "anthropic", "google"] = args.provider
- with_bash: bool = args.with_bash
-
- provider_upper = provider.upper()
- base_url = os.getenv(f"{provider_upper}_BASE_URL")
- api_key = os.getenv(f"{provider_upper}_API_KEY")
- model = os.getenv(f"{provider_upper}_MODEL_NAME")
-
- match provider:
- case "kimi":
- from kosong.chat_provider.kimi import Kimi
-
- base_url = base_url or "https://api.moonshot.ai/v1"
- assert api_key is not None, "Expect KIMI_API_KEY environment variable"
- model = model or "kimi-k2-turbo-preview"
-
- chat_provider = Kimi(base_url=base_url, api_key=api_key, model=model)
- case "openai":
- from kosong.contrib.chat_provider.openai_responses import OpenAIResponses
-
- base_url = base_url or "https://api.openai.com/v1"
- assert api_key is not None, "Expect OPENAI_API_KEY environment variable"
- model = model or "gpt-5"
-
- chat_provider = OpenAIResponses(base_url=base_url, api_key=api_key, model=model)
- case "anthropic":
- from kosong.contrib.chat_provider.anthropic import Anthropic
-
- base_url = base_url or "https://api.anthropic.com"
- assert api_key is not None, "Expect ANTHROPIC_API_KEY environment variable"
- model = model or "claude-sonnet-4-5"
-
- chat_provider = Anthropic(
- base_url=base_url, api_key=api_key, model=model, default_max_tokens=50_000
- )
- case "google":
- from kosong.contrib.chat_provider.google_genai import GoogleGenAI
-
- api_key = api_key or os.getenv("GEMINI_API_KEY")
- assert api_key is not None, (
- "Expect GOOGLE_API_KEY or GEMINI_API_KEY environment variable"
- )
- model = model or "gemini-3-pro-preview"
- chat_provider = GoogleGenAI(
- base_url=base_url, api_key=api_key, model=model
- ).with_thinking("high")
-
- toolset = SimpleToolset()
- if with_bash:
- toolset += BashTool()
-
- await agent_loop(chat_provider, toolset)
-
-
-asyncio.run(main())
diff --git a/src/kosong/_generate.py b/src/kosong/_generate.py
deleted file mode 100644
index 68a1014..0000000
--- a/src/kosong/_generate.py
+++ /dev/null
@@ -1,106 +0,0 @@
-from collections.abc import Sequence
-from dataclasses import dataclass
-
-from loguru import logger
-
-from kosong.chat_provider import (
- APIEmptyResponseError,
- ChatProvider,
- StreamedMessagePart,
- TokenUsage,
-)
-from kosong.message import ContentPart, Message, ToolCall
-from kosong.tooling import Tool
-from kosong.utils.aio import Callback, callback
-
-
-async def generate(
- chat_provider: ChatProvider,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- *,
- on_message_part: Callback[[StreamedMessagePart], None] | None = None,
- on_tool_call: Callback[[ToolCall], None] | None = None,
-) -> "GenerateResult":
- """
- Generate one message based on the given context.
- Parts of the message will be streamed to the specified callbacks if provided.
-
- Args:
- chat_provider: The chat provider to use for generation.
- system_prompt: The system prompt to use for generation.
- tools: The tools available for the model to call.
- history: The message history to use for generation.
- on_message_part: An optional callback to be called for each raw message part.
- on_tool_call: An optional callback to be called for each complete tool call.
-
- Returns:
- A tuple of the generated message and the token usage (if available).
- All parts in the message are guaranteed to be complete and merged as much as possible.
-
- Raises:
- APIConnectionError: If the API connection fails.
- APITimeoutError: If the API request times out.
- APIStatusError: If the API returns a status code of 4xx or 5xx.
- APIEmptyResponseError: If the API returns an empty response.
- ChatProviderError: If any other recognized chat provider error occurs.
- """
- message = Message(role="assistant", content=[])
- pending_part: StreamedMessagePart | None = None # message part that is currently incomplete
-
- logger.trace("Generating with history: {history}", history=history)
- stream = await chat_provider.generate(system_prompt, tools, history)
- async for part in stream:
- logger.trace("Received part: {part}", part=part)
- if on_message_part:
- await callback(on_message_part, part.model_copy(deep=True))
-
- if pending_part is None:
- pending_part = part
- elif not pending_part.merge_in_place(part): # try merge into the pending part
- # unmergeable part must push the pending part to the buffer
- _message_append(message, pending_part)
- if isinstance(pending_part, ToolCall) and on_tool_call:
- await callback(on_tool_call, pending_part)
- pending_part = part
-
- # end of message
- if pending_part is not None:
- _message_append(message, pending_part)
- if isinstance(pending_part, ToolCall) and on_tool_call:
- await callback(on_tool_call, pending_part)
-
- if not message.content and not message.tool_calls:
- raise APIEmptyResponseError("The API returned an empty response.")
-
- return GenerateResult(
- id=stream.id,
- message=message,
- usage=stream.usage,
- )
-
-
-@dataclass(frozen=True, slots=True)
-class GenerateResult:
- """The result of a generation."""
-
- id: str | None
- """The ID of the generated message."""
- message: Message
- """The generated message."""
- usage: TokenUsage | None
- """The token usage of the generated message."""
-
-
-def _message_append(message: Message, part: StreamedMessagePart) -> None:
- match part:
- case ContentPart():
- message.content.append(part)
- case ToolCall():
- if message.tool_calls is None:
- message.tool_calls = []
- message.tool_calls.append(part)
- case _:
- # may be an orphaned `ToolCallPart`
- return
diff --git a/src/kosong/chat_provider/__init__.py b/src/kosong/chat_provider/__init__.py
deleted file mode 100644
index b2f5384..0000000
--- a/src/kosong/chat_provider/__init__.py
+++ /dev/null
@@ -1,126 +0,0 @@
-from collections.abc import AsyncIterator, Sequence
-from dataclasses import dataclass
-from typing import Literal, Protocol, Self, runtime_checkable
-
-from kosong.message import ContentPart, Message, ToolCall, ToolCallPart
-from kosong.tooling import Tool
-
-
-@runtime_checkable
-class ChatProvider(Protocol):
- """The interface of chat providers."""
-
- name: str
- """
- The name of the chat provider.
- """
-
- @property
- def model_name(self) -> str:
- """
- The name of the model to use.
- """
- ...
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "StreamedMessage":
- """
- Generate a new message based on the given system prompt, tools, and history.
-
- Raises:
- APIConnectionError: If the API connection fails.
- APITimeoutError: If the API request times out.
- APIStatusError: If the API returns a status code of 4xx or 5xx.
- ChatProviderError: If any other recognized chat provider error occurs.
- """
- ...
-
- def with_thinking(self, effort: "ThinkingEffort") -> Self:
- """
- Return a copy of self configured with the given thinking effort.
- If the chat provider does not support thinking, simply return a copy of self.
- """
- ...
-
-
-type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart
-
-
-@runtime_checkable
-class StreamedMessage(Protocol):
- """The interface of streamed messages."""
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- """Create an async iterator from the stream."""
- ...
-
- @property
- def id(self) -> str | None:
- """The ID of the streamed message."""
- ...
-
- @property
- def usage(self) -> "TokenUsage | None":
- """The token usage of the streamed message."""
- ...
-
-
-@dataclass(frozen=True, kw_only=True, slots=True)
-class TokenUsage:
- """Token usage statistics."""
-
- input_other: int
- """Input tokens excluding `input_cache_read` and `input_cache_creation`."""
- output: int
- """Total output tokens."""
- input_cache_read: int = 0
- """Cached input tokens."""
- input_cache_creation: int = 0
- """Input tokens used for cache creation. For now, only Anthropic API supports this."""
-
- @property
- def total(self) -> int:
- """Total tokens used, including input and output tokens."""
- return self.input + self.output
-
- @property
- def input(self) -> int:
- """Total input tokens, including cached and uncached tokens."""
- return self.input_other + self.input_cache_read + self.input_cache_creation
-
-
-type ThinkingEffort = Literal["off", "low", "medium", "high"]
-"""The effort level for thinking."""
-
-
-class ChatProviderError(Exception):
- """The error raised by a chat provider."""
-
- def __init__(self, message: str):
- super().__init__(message)
-
-
-class APIConnectionError(ChatProviderError):
- """The error raised when the API connection fails."""
-
-
-class APITimeoutError(ChatProviderError):
- """The error raised when the API request times out."""
-
-
-class APIStatusError(ChatProviderError):
- """The error raised when the API returns a status code of 4xx or 5xx."""
-
- status_code: int
-
- def __init__(self, status_code: int, message: str):
- super().__init__(message)
- self.status_code = status_code
-
-
-class APIEmptyResponseError(ChatProviderError):
- """The error raised when the API returns an empty response."""
diff --git a/src/kosong/chat_provider/chaos.py b/src/kosong/chat_provider/chaos.py
deleted file mode 100644
index 76caeca..0000000
--- a/src/kosong/chat_provider/chaos.py
+++ /dev/null
@@ -1,278 +0,0 @@
-import json
-import os
-import random
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Any
-
-import httpx
-from pydantic import BaseModel
-
-from kosong.chat_provider import (
- ChatProvider,
- ChatProviderError,
- StreamedMessage,
- StreamedMessagePart,
- ThinkingEffort,
- TokenUsage,
-)
-from kosong.message import Message, ToolCall, ToolCallPart
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(
- chaos: "ChaosChatProvider",
- ):
- _: ChatProvider = chaos
-
-
-class ChaosConfig(BaseModel):
- """Configuration for chaos provider."""
-
- error_probability: float = 0.3
- error_types: list[int] = [429, 500, 502, 503]
- retry_after: int = 2
- seed: int | None = None
- corrupt_tool_call_probability: float = 0.1
-
- @classmethod
- def from_env(cls) -> "ChaosConfig":
- """Create config from environment variables."""
- seed_str = os.getenv("CHAOS_SEED")
- return cls(
- error_probability=float(os.getenv("CHAOS_ERROR_PROBABILITY", "0.3")),
- error_types=[
- int(x.strip()) for x in os.getenv("CHAOS_ERROR_TYPES", "429,500,502,503").split(",")
- ],
- retry_after=int(os.getenv("CHAOS_RETRY_AFTER", "2")),
- seed=int(seed_str) if seed_str else None,
- corrupt_tool_call_probability=float(
- os.getenv("CHAOS_CORRUPT_TOOL_CALL_PROBABILITY", "0.1")
- ),
- )
-
-
-class ChaosTransport(httpx.AsyncBaseTransport):
- """HTTP transport that randomly injects errors."""
-
- def __init__(self, wrapped_transport: httpx.AsyncBaseTransport, config: ChaosConfig):
- self._wrapped = wrapped_transport
- self._config = config
- self._rng = random.Random(config.seed)
-
- async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
- if self._should_inject_error():
- error_code = self._rng.choice(self._config.error_types)
- return self._create_error_response(request, error_code)
-
- return await self._wrapped.handle_async_request(request)
-
- def _should_inject_error(self) -> bool:
- return self._rng.random() < self._config.error_probability
-
- def _create_error_response(self, request: httpx.Request, status_code: int) -> httpx.Response:
- error_messages = {
- 429: {"error": {"code": "rate_limit_exceeded", "message": "Rate limit exceeded"}},
- 500: {"error": {"code": "internal_error", "message": "Internal server error"}},
- 502: {"error": {"code": "bad_gateway", "message": "Bad gateway"}},
- 503: {
- "error": {
- "code": "service_unavailable",
- "message": "Service temporarily unavailable",
- }
- },
- }
-
- content = json.dumps(
- error_messages.get(status_code, {"error": {"message": "Unknown error"}})
- )
- headers = {"content-type": "application/json"}
-
- if status_code == 429:
- headers["retry-after"] = str(self._config.retry_after)
-
- return httpx.Response(
- status_code=status_code,
- headers=headers,
- content=content.encode(),
- request=request,
- )
-
-
-class ChaosChatProvider:
- """Wrap a chat provider and inject chaos into its HTTP transport and streamed tool calls."""
-
- def __init__(self, provider: ChatProvider, chaos_config: ChaosConfig | None = None):
- self._provider = provider
- self._chaos_config = chaos_config or ChaosConfig.from_env()
- self.name: str = provider.name
- self._monkey_patch_client()
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "ChaosStreamedMessage":
- base_stream = await self._provider.generate(system_prompt, tools, history)
- return ChaosStreamedMessage(base_stream, self._chaos_config)
-
- def _monkey_patch_client(self):
- """
- Inject chaos transport into providers backed by httpx AsyncBaseTransport.
-
- Supported today (explicit list):
- - Kimi
- - OpenAILegacy
- - Anthropic
-
- The provider must expose an AsyncOpenAI/Anthropic/httpx client via `.client`,
- `.client._client`, or `._client`. Providers without an accessible httpx transport
- will raise ChatProviderError.
- """
- transport_owner = self._find_transport_owner()
- transport = getattr(transport_owner, "_transport", None)
- if not isinstance(transport, httpx.AsyncBaseTransport):
- raise ChatProviderError(
- "ChaosChatProvider only supports providers backed by httpx.AsyncBaseTransport"
- )
-
- chaos_transport = ChaosTransport(transport, self._chaos_config)
- transport_owner._transport = chaos_transport # pyright: ignore[reportPrivateUsage]
-
- def _find_transport_owner(self) -> Any:
- """Locate the object that owns the httpx transport."""
- candidates: list[Any] = []
-
- client = getattr(self._provider, "client", None)
- if client is not None:
- candidates.append(client)
- raw_client = getattr(client, "_client", None)
- if raw_client is not None:
- candidates.append(raw_client)
-
- inner_client = getattr(self._provider, "_client", None)
- if inner_client is not None:
- candidates.append(inner_client)
-
- for owner in candidates:
- if hasattr(owner, "_transport"):
- return owner
- nested = getattr(owner, "_client", None)
- if nested and hasattr(nested, "_transport"):
- return nested
-
- raise ChatProviderError(
- "ChaosChatProvider only supports providers backed by httpx.AsyncBaseTransport"
- )
-
- @property
- def model_name(self) -> str:
- if (
- self._chaos_config.error_probability > 0
- or self._chaos_config.corrupt_tool_call_probability > 0
- ):
- return f"chaos({self._provider.model_name})"
- return self._provider.model_name
-
- def with_thinking(self, effort: ThinkingEffort) -> "ChaosChatProvider":
- return ChaosChatProvider(self._provider.with_thinking(effort), self._chaos_config)
-
- @classmethod
- def for_kimi(
- cls, chaos_config: ChaosConfig | None = None, **kwargs: Any
- ) -> "ChaosChatProvider":
- """Helper to wrap a Kimi provider without changing caller sites."""
- from kosong.chat_provider.kimi import Kimi
-
- return cls(Kimi(**kwargs), chaos_config=chaos_config)
-
-
-class ChaosStreamedMessage:
- """Stream wrapper that injects chaos into tool calls."""
-
- def __init__(self, wrapped: StreamedMessage, config: ChaosConfig):
- self._wrapped = wrapped
- self._config = config
- self._rng = random.Random(config.seed)
- self._iterator = wrapped.__aiter__()
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- part = await self._iterator.__anext__()
- return self._maybe_corrupt_tool_call(part)
-
- @property
- def id(self) -> str | None:
- return self._wrapped.id
-
- @property
- def usage(self) -> TokenUsage | None:
- return self._wrapped.usage
-
- def _should_corrupt_tool_call(self) -> bool:
- probability = self._config.corrupt_tool_call_probability
- return probability > 0 and self._rng.random() < probability
-
- def _maybe_corrupt_tool_call(self, part: StreamedMessagePart) -> StreamedMessagePart:
- if not self._should_corrupt_tool_call():
- return part
- if isinstance(part, ToolCall):
- return self._corrupt_tool_call(part)
- if isinstance(part, ToolCallPart):
- return self._corrupt_tool_call_part(part)
- return part
-
- def _corrupt_tool_call(self, tool_call: ToolCall) -> StreamedMessagePart:
- arguments = tool_call.function.arguments
- if arguments is None or not arguments.endswith("}"):
- return tool_call
- corrupted = tool_call.model_copy(deep=True)
- corrupted.function.arguments = arguments[:-1]
- return corrupted
-
- def _corrupt_tool_call_part(self, part: ToolCallPart) -> StreamedMessagePart:
- arguments = part.arguments_part
- if arguments is None or not arguments.endswith("}"):
- return part
- corrupted = part.model_copy(deep=True)
- corrupted.arguments_part = arguments[:-1]
- return corrupted
-
-
-if __name__ == "__main__":
-
- async def _dev_main_anthropic():
- from dotenv import load_dotenv
-
- from kosong.contrib.chat_provider.anthropic import Anthropic
- from kosong.message import Message, TextPart
-
- load_dotenv()
-
- provider = Anthropic(
- model="claude-3-5-sonnet-latest",
- api_key=os.getenv("ANTHROPIC_API_KEY"),
- default_max_tokens=64,
- stream=True,
- )
- chat = ChaosChatProvider(
- provider,
- ChaosConfig(
- error_probability=0.0,
- corrupt_tool_call_probability=0.2,
- seed=42,
- ),
- )
- history = [Message(role="user", content=[TextPart(text="Say hello briefly.")])]
- stream = await chat.generate(system_prompt="", tools=[], history=history)
- async for part in stream:
- print(part.model_dump(exclude_none=True))
- print("id:", stream.id)
- print("usage:", stream.usage)
-
- import asyncio
-
- asyncio.run(_dev_main_anthropic())
diff --git a/src/kosong/chat_provider/echo.py b/src/kosong/chat_provider/echo.py
deleted file mode 100644
index b3618f7..0000000
--- a/src/kosong/chat_provider/echo.py
+++ /dev/null
@@ -1,300 +0,0 @@
-from __future__ import annotations
-
-import copy
-import json
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Any, Self, cast
-
-from kosong.chat_provider import (
- ChatProvider,
- ChatProviderError,
- StreamedMessage,
- StreamedMessagePart,
- ThinkingEffort,
- TokenUsage,
-)
-from kosong.message import (
- AudioURLPart,
- ImageURLPart,
- Message,
- TextPart,
- ThinkPart,
- ToolCall,
- ToolCallPart,
-)
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(echo: EchoChatProvider):
- _: ChatProvider = echo
-
-
-class EchoChatProvider:
- """
- A test-only chat provider that streams parts described by a tiny DSL.
-
- The DSL lives in the content of the last message in `history` and is made of lines in the
- form `kind: payload`. Empty lines, comment lines starting with `#`, and markdown fences
- starting with ``` are ignored. Supported kinds:
-
- - `id`: sets the streamed message id.
- - `usage`: token usage, e.g. `usage: {"input_other": 10, "output": 2}` or
- `usage: input_other=1 output=2 input_cache_read=3`.
- - `text`: a text chunk.
- - `think`: a thinking chunk.
- - `image_url`: either a raw URL or `{"url": "...", "id": "opt"}`.
- - `audio_url`: either a raw URL or `{"url": "...", "id": "opt"}`.
- - `tool_call`: a JSON or key/value object. Fields: `id`, `name` (or `function.name`),
- optional `arguments`/`function.arguments`, optional `extras`.
- - `tool_call_part`: a string/JSON with `arguments_part`; `null` becomes `None`.
-
- Example:
-
- ```
- id: echo-42
- usage: {"input_other": 10, "output": 2}
- think: thinking...
- text: Hello,
- text: world!
- image_url: {"url": "https://example.com/image.png", "id": "img-1"}
- tool_call: {"id": "call-1", "name": "search", "arguments": "{\\"query"}
- tool_call_part: {"arguments_part": "\\": \\"what time is"}
- tool_call_part: {"arguments_part": " it?\\"}"}
- ```
- """
-
- name = "echo"
-
- @property
- def model_name(self) -> str:
- return "echo"
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> EchoStreamedMessage:
- if not history:
- raise ChatProviderError("EchoChatProvider requires at least one message in history.")
- if history[-1].role != "user":
- raise ChatProviderError("EchoChatProvider expects the last history message to be user.")
-
- script_text = history[-1].extract_text()
- parts, message_id, usage = self._parse_script(script_text)
- if not parts:
- raise ChatProviderError("EchoChatProvider DSL produced no streamable parts.")
- return EchoStreamedMessage(parts=parts, message_id=message_id, usage=usage)
-
- def with_thinking(self, effort: ThinkingEffort) -> Self:
- # Thinking effort is irrelevant to the echo provider; return a shallow copy to
- # satisfy the protocol and keep the instance immutable.
- return copy.copy(self)
-
- def _parse_script(
- self, script: str
- ) -> tuple[list[StreamedMessagePart], str | None, TokenUsage | None]:
- parts: list[StreamedMessagePart] = []
- message_id: str | None = None
- usage: TokenUsage | None = None
-
- for lineno, raw_line in enumerate(script.splitlines(), start=1):
- line = raw_line.strip()
- if not line or line.startswith("#") or line.startswith("```"):
- continue
- if line.lower() == "echo":
- continue
- key, sep, payload = line.partition(":")
- if not sep:
- raise ChatProviderError(f"Invalid echo DSL at line {lineno}: {raw_line!r}")
-
- kind = key.strip().lower()
- payload = payload[1:] if payload.startswith(" ") else payload
- if kind == "id":
- message_id = self._strip_quotes(payload.strip())
- continue
- if kind == "usage":
- usage = self._parse_usage(payload)
- continue
-
- part = self._parse_part(kind, payload, lineno, raw_line)
- parts.append(part)
-
- return parts, message_id, usage
-
- def _parse_part(
- self, kind: str, payload: str, lineno: int, raw_line: str
- ) -> StreamedMessagePart:
- match kind:
- case "text":
- return TextPart(text=self._strip_quotes(payload))
- case "think":
- return ThinkPart(think=self._strip_quotes(payload))
- case "image_url":
- url, image_id = self._parse_url_payload(payload, kind)
- return ImageURLPart(image_url=ImageURLPart.ImageURL(url=url, id=image_id))
- case "audio_url":
- url, audio_id = self._parse_url_payload(payload, kind)
- return AudioURLPart(audio_url=AudioURLPart.AudioURL(url=url, id=audio_id))
- case "tool_call":
- return self._parse_tool_call(payload, lineno, raw_line)
- case "tool_call_part":
- return self._parse_tool_call_part(payload)
- case _:
- raise ChatProviderError(
- f"Unknown echo DSL kind '{kind}' at line {lineno}: {raw_line!r}"
- )
-
- def _parse_usage(self, payload: str) -> TokenUsage:
- mapping = self._parse_mapping(payload, context="usage")
-
- def _int_value(key: str) -> int:
- value = mapping.get(key, 0)
- try:
- return int(value)
- except (TypeError, ValueError):
- raise ChatProviderError(
- f"Usage field '{key}' must be an integer, got {value!r}"
- ) from None
-
- return TokenUsage(
- input_other=_int_value("input_other"),
- output=_int_value("output"),
- input_cache_read=_int_value("input_cache_read"),
- input_cache_creation=_int_value("input_cache_creation"),
- )
-
- def _parse_url_payload(self, payload: str, kind: str) -> tuple[str, str | None]:
- value = self._parse_value(payload)
- if isinstance(value, dict):
- mapping = cast(dict[str, Any], value)
- url = mapping.get("url")
- if not isinstance(url, str):
- raise ChatProviderError(f"{kind} requires a url field, got {mapping!r}")
- content_id = mapping.get("id")
- if content_id is not None and not isinstance(content_id, str):
- raise ChatProviderError(f"{kind} id must be a string when provided.")
- return url, content_id
- if not isinstance(value, str):
- raise ChatProviderError(f"{kind} expects url string or object, got {value!r}")
- return value, None
-
- def _parse_tool_call(self, payload: str, lineno: int, raw_line: str) -> ToolCall:
- mapping = self._parse_mapping(payload, context="tool_call")
- function = mapping.get("function") if isinstance(mapping.get("function"), dict) else None
-
- tool_call_id = mapping.get("id")
- name = mapping.get("name") or (function.get("name") if function else None)
- arguments = mapping.get("arguments")
- extras = mapping.get("extras")
-
- if function:
- if arguments is None:
- arguments = function.get("arguments")
- if extras is None:
- extras = function.get("extras")
-
- if not isinstance(tool_call_id, str) or not isinstance(name, str):
- raise ChatProviderError(
- f"tool_call requires string id and name at line {lineno}: {raw_line!r}"
- )
-
- if arguments is not None and not isinstance(arguments, str):
- raise ChatProviderError(
- "tool_call.arguments must be a string at line "
- f"{lineno}, got {type(arguments).__name__}"
- )
-
- return ToolCall(
- id=tool_call_id,
- function=ToolCall.FunctionBody(name=name, arguments=arguments),
- extras=cast(dict[str, Any], extras) if isinstance(extras, dict) else None,
- )
-
- def _parse_tool_call_part(self, payload: str) -> ToolCallPart:
- value = self._parse_value(payload)
- if isinstance(value, dict):
- value = cast(dict[str, Any], value)
- arguments_part: Any | None = value.get("arguments_part")
- else:
- arguments_part = value
- if isinstance(arguments_part, (dict, list)):
- arguments_part = json.dumps(arguments_part, separators=(",", ":"))
- return ToolCallPart(arguments_part=None if arguments_part in (None, "") else arguments_part)
-
- def _parse_mapping(self, raw: str, *, context: str) -> dict[str, Any]:
- raw = raw.strip()
- try:
- loaded = json.loads(raw)
- except json.JSONDecodeError:
- loaded = None
- if isinstance(loaded, dict):
- return cast(dict[str, Any], loaded)
- if loaded is not None:
- raise ChatProviderError(f"{context} payload must be an object, got {loaded!r}")
-
- mapping: dict[str, Any] = {}
- for token in raw.replace(",", " ").split():
- if not token:
- continue
- if "=" not in token:
- raise ChatProviderError(f"Invalid token '{token}' in {context} payload.")
- key, value = token.split("=", 1)
- mapping[key.strip()] = self._parse_value(value.strip())
-
- if not mapping:
- raise ChatProviderError(f"{context} payload cannot be empty.")
- return mapping
-
- def _parse_value(self, raw: str) -> Any:
- raw = raw.strip()
- if not raw:
- return None
- lowered = raw.lower()
- if lowered in {"null", "none"}:
- return None
- try:
- return json.loads(raw)
- except json.JSONDecodeError:
- return self._strip_quotes(raw)
-
- def _strip_quotes(self, value: str) -> str:
- if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
- return value[1:-1]
- return value
-
-
-class EchoStreamedMessage(StreamedMessage):
- """Streamed message for EchoChatProvider."""
-
- def __init__(
- self,
- *,
- parts: list[StreamedMessagePart],
- message_id: str | None,
- usage: TokenUsage | None,
- ):
- self._iter = self._to_stream(parts)
- self._id = message_id
- self._usage = usage
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- async def _to_stream(
- self, parts: list[StreamedMessagePart]
- ) -> AsyncIterator[StreamedMessagePart]:
- for part in parts:
- yield part
-
- @property
- def id(self) -> str | None:
- return self._id
-
- @property
- def usage(self) -> TokenUsage | None:
- return self._usage
diff --git a/src/kosong/chat_provider/kimi.py b/src/kosong/chat_provider/kimi.py
deleted file mode 100644
index a976639..0000000
--- a/src/kosong/chat_provider/kimi.py
+++ /dev/null
@@ -1,346 +0,0 @@
-import copy
-import os
-import uuid
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast
-
-import httpx
-from openai import AsyncOpenAI, AsyncStream, OpenAIError, omit
-from openai.types.chat import (
- ChatCompletion,
- ChatCompletionChunk,
- ChatCompletionMessageFunctionToolCall,
- ChatCompletionMessageParam,
- ChatCompletionToolParam,
-)
-from openai.types.completion_usage import CompletionUsage
-
-from kosong.chat_provider import (
- ChatProvider,
- ChatProviderError,
- StreamedMessage,
- StreamedMessagePart,
- ThinkingEffort,
- TokenUsage,
-)
-from kosong.chat_provider.openai_common import convert_error, tool_to_openai
-from kosong.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(kimi: "Kimi"):
- _: ChatProvider = kimi
-
-
-class Kimi(ChatProvider):
- """
- A chat provider that uses the Kimi API.
-
- >>> chat_provider = Kimi(model="kimi-k2-turbo-preview", api_key="sk-1234567890")
- >>> chat_provider.name
- 'kimi'
- >>> chat_provider.model_name
- 'kimi-k2-turbo-preview'
- >>> chat_provider.with_generation_kwargs(temperature=0)._generation_kwargs
- {'temperature': 0}
- >>> chat_provider._generation_kwargs
- {}
- """
-
- name = "kimi"
-
- class GenerationKwargs(TypedDict, total=False):
- """
- See https://platform.moonshot.ai/docs/api/chat#request-body.
- """
-
- max_tokens: int | None
- temperature: float | None
- top_p: float | None
- n: int | None
- presence_penalty: float | None
- frequency_penalty: float | None
- stop: str | list[str] | None
- prompt_cache_key: str | None
- reasoning_effort: str | None
-
- def __init__(
- self,
- *,
- model: str,
- api_key: str | None = None,
- base_url: str | None = None,
- stream: bool = True,
- **client_kwargs: Any,
- ):
- if api_key is None:
- api_key = os.getenv("KIMI_API_KEY")
- if api_key is None:
- raise ChatProviderError(
- "The api_key client option or the KIMI_API_KEY environment variable is not set"
- )
- if base_url is None:
- base_url = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1")
-
- self.model: str = model
- """The name of the model to use."""
- self.stream: bool = stream
- """Whether to generate responses as a stream."""
- self.client: AsyncOpenAI = AsyncOpenAI(
- api_key=api_key,
- base_url=base_url,
- **client_kwargs,
- )
- """The underlying `AsyncOpenAI` client."""
- self._generation_kwargs: Kimi.GenerationKwargs = {}
-
- @property
- def model_name(self) -> str:
- return self.model
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "KimiStreamedMessage":
- messages: list[ChatCompletionMessageParam] = []
- if system_prompt:
- messages.append({"role": "system", "content": system_prompt})
- messages.extend(_convert_message(message) for message in history)
-
- generation_kwargs: dict[str, Any] = {
- # default kimi generation kwargs
- "max_tokens": 32000,
- }
- generation_kwargs.update(self._generation_kwargs)
- if "temperature" not in generation_kwargs:
- # set default temperature based on model name
- if "kimi-k2-thinking" in self.model or self._generation_kwargs.get("reasoning_effort"):
- generation_kwargs["temperature"] = 1.0
- elif "kimi-k2-" in self.model:
- generation_kwargs["temperature"] = 0.6
-
- try:
- response = await self.client.chat.completions.create(
- model=self.model,
- messages=messages,
- tools=(_convert_tool(tool) for tool in tools),
- stream=self.stream,
- stream_options={"include_usage": True} if self.stream else omit,
- **generation_kwargs,
- )
- return KimiStreamedMessage(response)
- except (OpenAIError, httpx.HTTPError) as e:
- raise convert_error(e) from e
-
- def with_thinking(self, effort: ThinkingEffort) -> Self:
- match effort:
- case "off":
- reasoning_effort = None
- case "low":
- reasoning_effort = "low"
- case "medium":
- reasoning_effort = "medium"
- case "high":
- reasoning_effort = "high"
- return self.with_generation_kwargs(reasoning_effort=reasoning_effort)
-
- def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
- """
- Copy the chat provider, updating the generation kwargs with the given values.
-
- Returns:
- Self: A new instance of the chat provider with updated generation kwargs.
- """
- new_self = copy.copy(self)
- new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
- new_self._generation_kwargs.update(kwargs)
- return new_self
-
- @property
- def model_parameters(self) -> dict[str, Any]:
- """
- The parameters of the model to use.
-
- For tracing/logging purposes.
- """
-
- model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)}
- model_parameters.update(self._generation_kwargs)
- return model_parameters
-
-
-def _convert_message(message: Message) -> ChatCompletionMessageParam:
- message = message.model_copy(deep=True)
- reasoning_content: str = ""
- content: list[ContentPart] = []
- for part in message.content:
- if isinstance(part, ThinkPart):
- reasoning_content += part.think
- else:
- content.append(part)
- message.content = content
- dumped_message = message.model_dump(exclude_none=True)
- if reasoning_content:
- dumped_message["reasoning_content"] = reasoning_content
- return cast(ChatCompletionMessageParam, dumped_message)
-
-
-def _convert_tool(tool: Tool) -> ChatCompletionToolParam:
- if tool.name.startswith("$"):
- # Kimi builtin functions start with `$`
- return cast(
- ChatCompletionToolParam,
- {
- "type": "builtin_function",
- "function": {
- "name": tool.name,
- # no need to set description and parameters
- },
- },
- )
- else:
- return tool_to_openai(tool)
-
-
-class KimiStreamedMessage(StreamedMessage):
- """The streamed message of the Kimi chat provider."""
-
- def __init__(self, response: ChatCompletion | AsyncStream[ChatCompletionChunk]):
- if isinstance(response, ChatCompletion):
- self._iter = self._convert_non_stream_response(response)
- else:
- self._iter = self._convert_stream_response(response)
- self._id: str | None = None
- self._usage: CompletionUsage | None = None
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- @property
- def id(self) -> str | None:
- return self._id
-
- @property
- def usage(self) -> TokenUsage | None:
- if self._usage:
- cached = 0
- other_input = self._usage.prompt_tokens
- if hasattr(self._usage, "cached_tokens"):
- # https://platform.moonshot.cn/docs/api/chat#%E8%BF%94%E5%9B%9E%E5%86%85%E5%AE%B9
- # TODO: delete this when Moonshot API becomes compatible with OpenAI API
- cached = getattr(self._usage, "cached_tokens") or 0 # noqa: B009
- other_input -= cached
- elif (
- self._usage.prompt_tokens_details
- and self._usage.prompt_tokens_details.cached_tokens
- ):
- cached = self._usage.prompt_tokens_details.cached_tokens
- other_input -= cached
- return TokenUsage(
- input_other=other_input,
- output=self._usage.completion_tokens,
- input_cache_read=cached,
- )
- return None
-
- async def _convert_non_stream_response(
- self,
- response: ChatCompletion,
- ) -> AsyncIterator[StreamedMessagePart]:
- self._id = response.id
- self._usage = response.usage
- message = response.choices[0].message
- if reasoning_content := getattr(message, "reasoning_content", None):
- assert isinstance(reasoning_content, str)
- yield ThinkPart(think=reasoning_content)
- if message.content:
- yield TextPart(text=message.content)
- if message.tool_calls:
- for tool_call in message.tool_calls:
- if isinstance(tool_call, ChatCompletionMessageFunctionToolCall):
- yield ToolCall(
- id=tool_call.id or str(uuid.uuid4()),
- function=ToolCall.FunctionBody(
- name=tool_call.function.name,
- arguments=tool_call.function.arguments,
- ),
- )
-
- async def _convert_stream_response(
- self,
- response: AsyncIterator[ChatCompletionChunk],
- ) -> AsyncIterator[StreamedMessagePart]:
- try:
- async for chunk in response:
- if chunk.id:
- self._id = chunk.id
- if chunk.usage:
- self._usage = chunk.usage
-
- if not chunk.choices:
- continue
-
- delta = chunk.choices[0].delta
-
- # convert thinking content
- if reasoning_content := getattr(delta, "reasoning_content", None):
- assert isinstance(reasoning_content, str)
- yield ThinkPart(think=reasoning_content)
-
- # convert text content
- if delta.content:
- yield TextPart(text=delta.content)
-
- # convert tool calls
- for tool_call in delta.tool_calls or []:
- if not tool_call.function:
- continue
-
- if tool_call.function.name:
- yield ToolCall(
- id=tool_call.id or str(uuid.uuid4()),
- function=ToolCall.FunctionBody(
- name=tool_call.function.name,
- arguments=tool_call.function.arguments,
- ),
- )
- elif tool_call.function.arguments:
- yield ToolCallPart(
- arguments_part=tool_call.function.arguments,
- )
- else:
- # skip empty tool calls
- pass
- except (OpenAIError, httpx.HTTPError) as e:
- raise convert_error(e) from e
-
-
-if __name__ == "__main__":
-
- async def _dev_main():
- chat = Kimi(model="kimi-k2-turbo-preview", stream=False)
- system_prompt = ""
- history = [
- Message(role="user", content="Hello, who is Confucius?"),
- ]
- stream = await chat.with_generation_kwargs(
- temperature=0,
- max_tokens=1000,
- ).generate(system_prompt, [], history)
- async for part in stream:
- print(part.model_dump(exclude_none=True))
- print("id:", stream.id)
- print("usage:", stream.usage)
-
- import asyncio
-
- from dotenv import load_dotenv
-
- load_dotenv()
- asyncio.run(_dev_main())
diff --git a/src/kosong/chat_provider/mock.py b/src/kosong/chat_provider/mock.py
deleted file mode 100644
index 65e09fd..0000000
--- a/src/kosong/chat_provider/mock.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import copy
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Self
-
-from kosong.chat_provider import (
- ChatProvider,
- StreamedMessage,
- StreamedMessagePart,
- ThinkingEffort,
- TokenUsage,
-)
-from kosong.message import Message
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(mock: "MockChatProvider"):
- _: ChatProvider = mock
-
-
-class MockChatProvider(ChatProvider):
- """
- A mock chat provider.
- """
-
- name = "mock"
-
- def __init__(
- self,
- message_parts: list[StreamedMessagePart],
- ):
- """Initialize the mock chat provider with predefined message parts."""
- self._message_parts = message_parts
-
- @property
- def model_name(self) -> str:
- return "mock"
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "MockStreamedMessage":
- """Always return the predefined message parts."""
- return MockStreamedMessage(self._message_parts)
-
- def with_thinking(self, effort: ThinkingEffort) -> Self:
- return copy.copy(self)
-
-
-class MockStreamedMessage(StreamedMessage):
- """The streamed message of the mock chat provider."""
-
- def __init__(self, message_parts: list[StreamedMessagePart]):
- self._iter = self._to_stream(message_parts)
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- async def _to_stream(
- self, message_parts: list[StreamedMessagePart]
- ) -> AsyncIterator[StreamedMessagePart]:
- for part in message_parts:
- yield part
-
- @property
- def id(self) -> str:
- return "mock"
-
- @property
- def usage(self) -> TokenUsage | None:
- return None
diff --git a/src/kosong/chat_provider/openai_common.py b/src/kosong/chat_provider/openai_common.py
deleted file mode 100644
index 284dc75..0000000
--- a/src/kosong/chat_provider/openai_common.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import httpx
-import openai
-from openai import OpenAIError
-from openai.types import ReasoningEffort
-from openai.types.chat import ChatCompletionToolParam
-
-from kosong.chat_provider import (
- APIConnectionError,
- APIStatusError,
- APITimeoutError,
- ChatProviderError,
- ThinkingEffort,
-)
-from kosong.tooling import Tool
-
-
-def convert_error(error: OpenAIError | httpx.HTTPError) -> ChatProviderError:
- match error:
- case openai.APIStatusError():
- return APIStatusError(error.status_code, error.message)
- case openai.APIConnectionError():
- return APIConnectionError(error.message)
- case openai.APITimeoutError():
- return APITimeoutError(error.message)
- case httpx.TimeoutException():
- return APITimeoutError(str(error))
- case httpx.NetworkError():
- return APIConnectionError(str(error))
- case httpx.HTTPStatusError():
- return APIStatusError(error.response.status_code, str(error))
- case _:
- return ChatProviderError(f"Error: {error}")
-
-
-def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffort:
- match effort:
- case "off":
- return None
- case "low":
- return "low"
- case "medium":
- return "medium"
- case "high":
- return "high"
-
-
-def tool_to_openai(tool: Tool) -> ChatCompletionToolParam:
- """Convert a single tool to OpenAI tool format."""
- # simply `model_dump` because the `Tool` type is OpenAI-compatible
- return {
- "type": "function",
- "function": {
- "name": tool.name,
- "description": tool.description,
- "parameters": tool.parameters,
- },
- }
diff --git a/src/kosong/contrib/__init__.py b/src/kosong/contrib/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/kosong/contrib/chat_provider/__init__.py b/src/kosong/contrib/chat_provider/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/kosong/contrib/chat_provider/anthropic.py b/src/kosong/contrib/chat_provider/anthropic.py
deleted file mode 100644
index ad52375..0000000
--- a/src/kosong/contrib/chat_provider/anthropic.py
+++ /dev/null
@@ -1,514 +0,0 @@
-try:
- import anthropic as _ # noqa: F401
-except ModuleNotFoundError as exc:
- raise ModuleNotFoundError(
- "Anthropic support requires the optional dependency 'anthropic'. "
- 'Install with `pip install "kosong[contrib]"`.'
- ) from exc
-
-import copy
-import json
-from collections.abc import AsyncIterator, Mapping, Sequence
-from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, Unpack, cast
-
-from anthropic import (
- AnthropicError,
- AsyncAnthropic,
- AsyncStream,
- omit,
-)
-from anthropic import (
- APIConnectionError as AnthropicAPIConnectionError,
-)
-from anthropic import (
- APIStatusError as AnthropicAPIStatusError,
-)
-from anthropic import (
- APITimeoutError as AnthropicAPITimeoutError,
-)
-from anthropic import (
- AuthenticationError as AnthropicAuthenticationError,
-)
-from anthropic import (
- PermissionDeniedError as AnthropicPermissionDeniedError,
-)
-from anthropic import (
- RateLimitError as AnthropicRateLimitError,
-)
-from anthropic.lib.streaming import MessageStopEvent
-from anthropic.types import (
- Base64ImageSourceParam,
- CacheControlEphemeralParam,
- ContentBlockParam,
- ImageBlockParam,
- MessageDeltaEvent,
- MessageDeltaUsage,
- MessageParam,
- MessageStartEvent,
- RawContentBlockDeltaEvent,
- RawContentBlockStartEvent,
- RawMessageStreamEvent,
- TextBlockParam,
- ThinkingBlockParam,
- ThinkingConfigParam,
- ToolChoiceParam,
- ToolParam,
- ToolResultBlockParam,
- ToolUseBlockParam,
- URLImageSourceParam,
- Usage,
-)
-from anthropic.types import (
- Message as AnthropicMessage,
-)
-from anthropic.types.tool_result_block_param import Content as ToolResultContent
-
-from kosong.chat_provider import (
- APIConnectionError,
- APIStatusError,
- APITimeoutError,
- ChatProvider,
- ChatProviderError,
- StreamedMessagePart,
- ThinkingEffort,
- TokenUsage,
-)
-from kosong.contrib.chat_provider.common import ToolMessageConversion
-from kosong.message import (
- ContentPart,
- ImageURLPart,
- Message,
- TextPart,
- ThinkPart,
- ToolCall,
- ToolCallPart,
-)
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(anthropic: "Anthropic"):
- _: ChatProvider = anthropic
-
-
-type MessagePayload = tuple[str | None, list[MessageParam]]
-
-type BetaFeatures = Literal["interleaved-thinking-2025-05-14"]
-
-
-class Anthropic:
- """
- Chat provider backed by Anthropic's Messages API.
- """
-
- name = "anthropic"
-
- class GenerationKwargs(TypedDict, total=False):
- max_tokens: int | None
- temperature: float | None
- top_k: int | None
- top_p: float | None
- # e.g., {"type": "enabled", "budget_tokens": 1024}
- thinking: ThinkingConfigParam | None
- # e.g., {"type": "auto", "disable_parallel_tool_use": True}
- tool_choice: ToolChoiceParam | None
-
- beta_features: list[BetaFeatures] | None
- extra_headers: Mapping[str, str] | None
-
- def __init__(
- self,
- *,
- model: str,
- api_key: str | None = None,
- base_url: str | None = None,
- stream: bool = True,
- # which process should we apply on tool result
- tool_message_conversion: ToolMessageConversion | None = None,
- # Must provide a max_tokens. Can be overridden by .with_generation_kwargs()
- default_max_tokens: int,
- **client_kwargs: Any,
- ):
- self._model = model
- self._stream = stream
- self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, **client_kwargs)
- self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion
- self._generation_kwargs: Anthropic.GenerationKwargs = {
- "max_tokens": default_max_tokens,
- "beta_features": ["interleaved-thinking-2025-05-14"],
- }
-
- @property
- def model_name(self) -> str:
- return self._model
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "AnthropicStreamedMessage":
- # https://docs.claude.com/en/api/messages#body-messages
- # Anthropic API does not support system roles, but just a system prompt.
- system = (
- [
- TextBlockParam(
- text=system_prompt,
- type="text",
- cache_control=CacheControlEphemeralParam(type="ephemeral"),
- )
- ]
- if system_prompt
- else omit
- )
- messages: list[MessageParam] = []
- for message in history:
- messages.append(self._convert_message(message))
- if messages:
- last_message = messages[-1]
- last_content = last_message["content"]
-
- # inject cache control in the last content.
- # https://docs.claude.com/en/docs/build-with-claude/prompt-caching
- if isinstance(last_content, list) and last_content:
- content_blocks = cast(list[ContentBlockParam], last_content)
- last_block = content_blocks[-1]
- match last_block["type"]:
- case (
- "text"
- | "image"
- | "document"
- | "search_result"
- | "tool_use"
- | "tool_result"
- | "server_tool_use"
- | "web_search_tool_result"
- ):
- last_block["cache_control"] = CacheControlEphemeralParam(type="ephemeral")
- case "thinking" | "redacted_thinking":
- pass
- generation_kwargs: dict[str, Any] = {}
- generation_kwargs.update(self._generation_kwargs)
- betas = generation_kwargs.pop("beta_features", [])
- extra_headers = {
- **{"anthropic-beta": ",".join(str(e) for e in betas)},
- **(generation_kwargs.pop("extra_headers", {})),
- }
-
- tools_ = [_convert_tool(tool) for tool in tools]
- if tools:
- tools_[-1]["cache_control"] = CacheControlEphemeralParam(type="ephemeral")
- try:
- response = await self._client.messages.create(
- model=self._model,
- messages=messages,
- system=system,
- tools=tools_,
- stream=self._stream,
- extra_headers=extra_headers,
- **generation_kwargs,
- )
- return AnthropicStreamedMessage(response)
- except AnthropicError as e:
- raise _convert_error(e) from e
-
- def with_thinking(self, effort: "ThinkingEffort") -> Self:
- # XXX: this is a heuristic mapping based on suggestions given by Claude
- thinking_config: ThinkingConfigParam
- match effort:
- case "off":
- thinking_config = {"type": "disabled"}
- case "low":
- thinking_config = {"type": "enabled", "budget_tokens": 1024}
- case "medium":
- thinking_config = {"type": "enabled", "budget_tokens": 4096}
- case "high":
- thinking_config = {"type": "enabled", "budget_tokens": 32_000}
- return self.with_generation_kwargs(thinking=thinking_config)
-
- def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
- """
- Copy the chat provider, updating the generation kwargs with the given values.
-
- Returns:
- Self: A new instance of the chat provider with updated generation kwargs.
- """
- new_self = copy.copy(self)
- new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
- new_self._generation_kwargs.update(kwargs)
- return new_self
-
- @property
- def model_parameters(self) -> dict[str, Any]:
- """
- The parameters of the model to use.
-
- For tracing/logging purposes.
- """
-
- model_parameters: dict[str, Any] = {"base_url": str(self._client.base_url)}
- model_parameters.update(self._generation_kwargs)
- return model_parameters
-
- def _convert_message(self, message: Message) -> MessageParam:
- """Convert a single internal message into Anthropic wire format."""
- role = message.role
-
- if role == "system":
- # Anthropic does not support system messages in the conversation.
- # We map it to a special user message.
- return MessageParam(
- role="user",
- content=[
- TextBlockParam(
- type="text", text=f"{message.extract_text(sep='\n')}"
- )
- ],
- )
- elif role == "tool":
- if message.tool_call_id is None:
- raise ChatProviderError("Tool message missing `tool_call_id`.")
- if self._tool_message_conversion == "extract_text":
- content = message.extract_text(sep="\n")
- else:
- content = message.content
- block = _tool_result_message_to_block(message.tool_call_id, content)
- return MessageParam(role="user", content=[block])
-
- assert role in ("user", "assistant")
- blocks: list[ContentBlockParam] = []
- for part in message.content:
- if isinstance(part, TextPart):
- blocks.append(TextBlockParam(type="text", text=part.text))
- elif isinstance(part, ImageURLPart):
- blocks.append(_image_url_part_to_anthropic(part))
- elif isinstance(part, ThinkPart):
- if part.encrypted is None:
- # missing signature, strip this thinking block.
- continue
- else:
- blocks.append(
- ThinkingBlockParam(
- type="thinking", thinking=part.think, signature=part.encrypted
- )
- )
- else:
- continue
- for tool_call in message.tool_calls or []:
- if tool_call.function.arguments:
- try:
- parsed_arguments = json.loads(tool_call.function.arguments)
- except json.JSONDecodeError as exc: # pragma: no cover - defensive guard
- raise ChatProviderError("Tool call arguments must be valid JSON.") from exc
- if not isinstance(parsed_arguments, dict):
- raise ChatProviderError("Tool call arguments must be a JSON object.")
- tool_input = cast(dict[str, object], parsed_arguments)
- else:
- tool_input = {}
- blocks.append(
- ToolUseBlockParam(
- type="tool_use",
- id=tool_call.id,
- name=tool_call.function.name,
- input=tool_input,
- )
- )
- return MessageParam(role=role, content=blocks)
-
-
-class AnthropicStreamedMessage:
- def __init__(self, response: AnthropicMessage | AsyncStream[RawMessageStreamEvent]):
- if isinstance(response, AnthropicMessage):
- self._iter = self._convert_non_stream_response(response)
- else:
- self._iter = self._convert_stream_response(response)
- self._id: str | None = None
- self._usage = Usage(input_tokens=0, output_tokens=0)
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- @property
- def id(self) -> str | None:
- return self._id
-
- @property
- def usage(self) -> TokenUsage | None:
- # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
- return TokenUsage(
- # Note: in some Anthropic-compatible APIs, input_tokens can be None
- input_other=self._usage.input_tokens or 0,
- output=self._usage.output_tokens,
- input_cache_read=self._usage.cache_read_input_tokens or 0,
- input_cache_creation=self._usage.cache_creation_input_tokens or 0,
- )
-
- def _update_usage(self, delta_usage: MessageDeltaUsage) -> None:
- if delta_usage.cache_creation_input_tokens is not None:
- self._usage.cache_creation_input_tokens = delta_usage.cache_creation_input_tokens
- if delta_usage.cache_read_input_tokens is not None:
- self._usage.cache_read_input_tokens = delta_usage.cache_read_input_tokens
- if delta_usage.input_tokens is not None:
- self._usage.input_tokens = delta_usage.input_tokens
- if delta_usage.output_tokens is not None: # type: ignore
- self._usage.output_tokens = delta_usage.output_tokens
-
- async def _convert_non_stream_response(
- self,
- response: AnthropicMessage,
- ) -> AsyncIterator[StreamedMessagePart]:
- self._id = response.id
- self._usage = response.usage
- for block in response.content:
- match block.type:
- case "text":
- yield TextPart(text=block.text)
- case "thinking":
- yield ThinkPart(think=block.thinking, encrypted=block.signature)
- case "redacted_thinking":
- yield ThinkPart(think="", encrypted=block.data)
- case "tool_use":
- yield ToolCall(
- id=block.id,
- function=ToolCall.FunctionBody(
- name=block.name, arguments=json.dumps(block.input)
- ),
- )
- case _:
- continue
-
- async def _convert_stream_response(
- self,
- manager: AsyncStream[RawMessageStreamEvent],
- ) -> AsyncIterator[StreamedMessagePart]:
- try:
- async with manager as stream:
- async for event in stream:
- if isinstance(event, MessageStartEvent):
- self._id = event.message.id
- # Capture initial usage from start event
- # (contains initial prompt/input token usage)
- self._usage = event.message.usage
- elif isinstance(event, RawContentBlockStartEvent):
- block = event.content_block
- match block.type:
- case "text":
- yield TextPart(text=block.text)
- case "thinking":
- yield ThinkPart(think=block.thinking)
- case "redacted_thinking":
- yield ThinkPart(think="", encrypted=block.data)
- case "tool_use":
- yield ToolCall(
- id=block.id,
- function=ToolCall.FunctionBody(name=block.name, arguments=""),
- )
- case "server_tool_use" | "web_search_tool_result":
- # ignore
- continue
- elif isinstance(event, RawContentBlockDeltaEvent):
- delta = event.delta
- match delta.type:
- case "text_delta":
- yield TextPart(text=delta.text)
- case "thinking_delta":
- yield ThinkPart(think=delta.thinking)
- case "input_json_delta":
- yield ToolCallPart(arguments_part=delta.partial_json)
- case "signature_delta":
- yield ThinkPart(think="", encrypted=delta.signature)
- case "citations_delta":
- # ignore
- continue
- elif isinstance(event, MessageDeltaEvent):
- if event.usage:
- self._update_usage(event.usage)
- elif isinstance(event, MessageStopEvent):
- continue
- except AnthropicError as exc:
- raise _convert_error(exc) from exc
-
-
-def _convert_tool(tool: Tool) -> ToolParam:
- return {
- "name": tool.name,
- "description": tool.description,
- "input_schema": tool.parameters,
- }
-
-
-def _tool_result_message_to_block(
- tool_call_id: str, content: str | list[ContentPart]
-) -> ToolResultBlockParam:
- block_content: str | list[ToolResultContent]
- # If tool_result_process is `extract_text`, we join all text parts into one string
- if isinstance(content, str):
- block_content = content
- else:
- # Otherwise, map parts to content blocks
- blocks: list[ToolResultContent] = []
- for part in content:
- if isinstance(part, TextPart):
- if part.text:
- blocks.append(TextBlockParam(type="text", text=part.text))
- elif isinstance(part, ImageURLPart):
- blocks.append(_image_url_part_to_anthropic(part))
- else:
- # https://docs.claude.com/en/docs/build-with-claude/files#file-types-and-content-blocks
- # Anthropic API supports very limited file types
- raise ChatProviderError(
- f"Anthropic API does not support {type(part)} in tool result"
- )
- block_content = blocks
-
- return ToolResultBlockParam(
- type="tool_result",
- tool_use_id=tool_call_id,
- content=block_content,
- )
-
-
-def _image_url_part_to_anthropic(part: ImageURLPart) -> ImageBlockParam:
- url = part.image_url.url
- # data:[][;base64],
- if url.startswith("data:"):
- res = url[5:].split(";base64,", 1)
- if len(res) != 2:
- raise ChatProviderError(f"Invalid data URL for image: {url}")
- media_type, data = res
- if media_type not in ("image/png", "image/jpeg", "image/gif", "image/webp"):
- raise ChatProviderError(
- f"Unsupported media type for base64 image: {media_type}, url: {url}"
- )
- return ImageBlockParam(
- type="image",
- source=Base64ImageSourceParam(
- type="base64",
- data=data,
- media_type=media_type,
- ),
- )
- else:
- return ImageBlockParam(
- type="image",
- source=URLImageSourceParam(type="url", url=url),
- )
-
-
-def _convert_error(error: AnthropicError) -> ChatProviderError:
- if isinstance(error, AnthropicAPIStatusError):
- return APIStatusError(error.status_code, str(error))
- if isinstance(error, AnthropicAuthenticationError):
- return APIStatusError(getattr(error, "status_code", 401), str(error))
- if isinstance(error, AnthropicPermissionDeniedError):
- return APIStatusError(getattr(error, "status_code", 403), str(error))
- if isinstance(error, AnthropicRateLimitError):
- return APIStatusError(getattr(error, "status_code", 429), str(error))
- if isinstance(error, AnthropicAPIConnectionError):
- return APIConnectionError(str(error))
- if isinstance(error, AnthropicAPITimeoutError):
- return APITimeoutError(str(error))
- return ChatProviderError(f"Anthropic error: {error}")
diff --git a/src/kosong/contrib/chat_provider/common.py b/src/kosong/contrib/chat_provider/common.py
deleted file mode 100644
index d19bf99..0000000
--- a/src/kosong/contrib/chat_provider/common.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from __future__ import annotations
-
-from typing import Literal
-
-type ToolMessageConversion = Literal["extract_text"]
diff --git a/src/kosong/contrib/chat_provider/google_genai.py b/src/kosong/contrib/chat_provider/google_genai.py
deleted file mode 100644
index 7e7b3e7..0000000
--- a/src/kosong/contrib/chat_provider/google_genai.py
+++ /dev/null
@@ -1,731 +0,0 @@
-try:
- from google import genai as _ # noqa: F401
-except ModuleNotFoundError as exc:
- raise ModuleNotFoundError(
- "Google Gemini support requires the optional dependency 'google-genai'. "
- 'Install with `pip install "kosong[contrib]"`.'
- ) from exc
-
-import base64
-import copy
-import json
-import mimetypes
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast
-
-import httpx
-from google import genai
-from google.genai import client as genai_client
-from google.genai import errors as genai_errors
-from google.genai.types import (
- Content,
- FunctionCall,
- FunctionDeclaration,
- FunctionResponse,
- FunctionResponsePart,
- GenerateContentConfig,
- GenerateContentResponse,
- GenerateContentResponseUsageMetadata,
- HttpOptions,
- Part,
- ThinkingConfig,
- ThinkingLevel,
- Tool,
- ToolConfig,
-)
-
-from kosong.chat_provider import (
- APIStatusError,
- APITimeoutError,
- ChatProvider,
- ChatProviderError,
- StreamedMessagePart,
- ThinkingEffort,
- TokenUsage,
-)
-from kosong.message import (
- AudioURLPart,
- ContentPart,
- ImageURLPart,
- Message,
- TextPart,
- ThinkPart,
- ToolCall,
-)
-from kosong.tooling import Tool as KosongTool
-from kosong.tooling import ToolReturnValue
-
-if TYPE_CHECKING:
-
- def type_check(google_genai: "GoogleGenAI"):
- _: ChatProvider = google_genai
-
-
-class GoogleGenAI:
- """
- Chat provider backed by Google's Gemini API.
- """
-
- name = "google_genai"
-
- class GenerationKwargs(TypedDict, total=False):
- max_output_tokens: int | None
- temperature: float | None
- top_k: int | None
- top_p: float | None
- # Thinking configuration for supported models
- thinking_config: ThinkingConfig | None
- # Tool configuration
- tool_config: ToolConfig | None
- # Extra headers
- http_options: HttpOptions | None
-
- def __init__(
- self,
- *,
- model: str,
- api_key: str | None = None,
- base_url: str | None = None,
- stream: bool = True,
- vertexai: bool | None = None,
- **client_kwargs: Any,
- ):
- self._model = model
- self._stream = stream
- self._base_url = base_url
- self._client: genai_client.Client = genai.Client(
- http_options=HttpOptions(base_url=base_url),
- api_key=api_key,
- vertexai=vertexai,
- **client_kwargs,
- )
- self._generation_kwargs: GoogleGenAI.GenerationKwargs = {}
-
- @property
- def model_name(self) -> str:
- return self._model
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[KosongTool],
- history: Sequence[Message],
- ) -> "GoogleGenAIStreamedMessage":
- contents = messages_to_google_genai_contents(history)
-
- config = GenerateContentConfig(**self._generation_kwargs)
- config.system_instruction = system_prompt
- config.tools = [tool_to_google_genai(tool) for tool in tools]
-
- try:
- if self._stream:
- stream_response = await self._client.aio.models.generate_content_stream( # type: ignore[reportUnknownMemberType]
- model=self._model,
- contents=contents,
- config=config,
- )
- return GoogleGenAIStreamedMessage(stream_response)
- else:
- response = await self._client.aio.models.generate_content( # type: ignore[reportUnknownMemberType]
- model=self._model,
- contents=contents,
- config=config,
- )
- return GoogleGenAIStreamedMessage(response)
- except Exception as e: # genai_errors.APIError and others
- raise _convert_error(e) from e
-
- def with_thinking(self, effort: "ThinkingEffort") -> Self:
- thinking_config = ThinkingConfig(include_thoughts=True)
-
- # Map thinking effort to budget tokens
- if "gemini-3" in self._model:
- match effort:
- case "off":
- # use default thinking config
- pass
- case "low":
- thinking_config.thinking_level = ThinkingLevel.LOW
- case "medium":
- # FIXME: medium not supported yet, use high
- thinking_config.thinking_level = ThinkingLevel.HIGH
- case "high":
- thinking_config.thinking_level = ThinkingLevel.HIGH
- else:
- match effort:
- case "off":
- thinking_config.thinking_budget = 0
- thinking_config.include_thoughts = False
- case "low":
- thinking_config.thinking_budget = 1024
- thinking_config.include_thoughts = True
- case "medium":
- thinking_config.thinking_budget = 4096
- thinking_config.include_thoughts = True
- case "high":
- thinking_config.thinking_budget = 32_000
- thinking_config.include_thoughts = True
-
- return self.with_generation_kwargs(thinking_config=thinking_config)
-
- def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
- """
- Copy the chat provider, updating the generation kwargs with the given values.
-
- Returns:
- Self: A new instance of the chat provider with updated generation kwargs.
- """
- new_self = copy.copy(self)
- new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
- new_self._generation_kwargs.update(kwargs)
- return new_self
-
- @property
- def model_parameters(self) -> dict[str, Any]:
- """
- The parameters of the model to use.
-
- For tracing/logging purposes.
- """
- return {
- "model": self._model,
- "base_url": self._base_url,
- **self._generation_kwargs,
- }
-
-
-class GoogleGenAIStreamedMessage:
- def __init__(self, response: GenerateContentResponse | AsyncIterator[GenerateContentResponse]):
- if isinstance(response, GenerateContentResponse):
- self._iter = self._convert_non_stream_response(response)
- else:
- self._iter = self._convert_stream_response(response)
- self._id: str | None = None
- self._usage: GenerateContentResponseUsageMetadata | None = None
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- @property
- def id(self) -> str | None:
- return self._id
-
- @property
- def usage(self) -> TokenUsage | None:
- if self._usage is None:
- return None
- return TokenUsage(
- input_other=self._usage.prompt_token_count or 0,
- output=self._usage.candidates_token_count or 0,
- input_cache_read=self._usage.cached_content_token_count or 0,
- input_cache_creation=0,
- )
-
- async def _convert_non_stream_response(
- self,
- response: GenerateContentResponse,
- ) -> AsyncIterator[StreamedMessagePart]:
- # Extract usage information
- if response.usage_metadata:
- self._usage = response.usage_metadata
- # Extract ID if available
- if response.response_id is not None:
- self._id = response.response_id
-
- # Process candidates
- for candidate in response.candidates or []:
- parts = candidate.content.parts if candidate.content else None
- if not parts:
- continue
- for part in parts:
- async for message_part in self._process_part_async(part):
- yield message_part
-
- async def _convert_stream_response(
- self,
- response_stream: AsyncIterator[GenerateContentResponse],
- ) -> AsyncIterator[StreamedMessagePart]:
- try:
- async for response in response_stream:
- # Extract ID from first response
- if not self._id and response.response_id is not None:
- self._id = response.response_id
-
- # Extract usage information
- if response.usage_metadata:
- self._usage = response.usage_metadata
-
- # Process candidates
- for candidate in response.candidates or []:
- parts = candidate.content.parts if candidate.content else None
- if not parts:
- continue
- for part in parts:
- async for message_part in self._process_part_async(part):
- yield message_part
- except genai_errors.APIError as exc:
- raise _convert_error(exc) from exc
-
- def _process_part(self, part: Part):
- """Process a single part and yield message components (synchronous generator).
-
- Handles different part types from Gemini API:
- - synthetic thinking parts (part.thought is True)
- - encrypted thinking parts (part.thought_signature is not None)
- - text parts
- - function calls
- """
- if part.thought:
- # Synthetic thinking part
- if part.text:
- yield ThinkPart(think=part.text)
- elif part.text:
- # Regular text part
- yield TextPart(text=part.text)
- elif part.function_call:
- func_call = part.function_call
- if func_call.name is None:
- # Skip function calls without a name
- return
- id_ = func_call.id if func_call.id is not None else f"{id(func_call)}"
- tool_call_id = f"{func_call.name}_{id_}"
- # Gemini uses thought_signature to store the encrypted thinking signature.
- # part.thought is synthetic
- # See: https://colab.research.google.com/github/GoogleCloudPlatform/generative-ai/blob/main/gemini/thinking/intro_thought_signatures.ipynb
- thought_signature_b64 = (
- base64.b64encode(part.thought_signature).decode("ascii")
- if part.thought_signature
- else None
- )
- yield ToolCall(
- id=tool_call_id,
- function=ToolCall.FunctionBody(
- name=func_call.name,
- arguments=json.dumps(func_call.args) if func_call.args else "{}",
- ),
- extras={
- "thought_signature_b64": thought_signature_b64,
- }
- if thought_signature_b64
- else None,
- )
-
- async def _process_part_async(self, part: Part) -> AsyncIterator[StreamedMessagePart]:
- """Async wrapper for _process_part."""
- for message_part in self._process_part(part):
- yield message_part
-
-
-def tool_to_google_genai(tool: KosongTool) -> Tool:
- """Convert a Kosong tool to GoogleGenAI tool format."""
- # Kosong already validates parameters as JSON Schema format via jsonschema
- # The google-genai SDK accepts dict format and internally converts to Schema
- return Tool(
- function_declarations=[
- FunctionDeclaration(
- name=tool.name,
- description=tool.description,
- parameters=tool.parameters, # type: ignore[arg-type] # GoogleGenAI accepts dict
- )
- ]
- )
-
-
-def _image_url_part_to_google_genai(part: ImageURLPart) -> Part:
- """Convert an image URL part to GoogleGenAI format."""
- url = part.image_url.url
-
- # Handle data URLs
- if url.startswith("data:"):
- # data:[][;base64],
- res = url[5:].split(";base64,", 1)
- if len(res) != 2:
- raise ChatProviderError(f"Invalid data URL for image: {url}")
-
- media_type, data_b64 = res
- if media_type not in ("image/png", "image/jpeg", "image/gif", "image/webp"):
- raise ChatProviderError(
- f"Unsupported media type for base64 image: {media_type}, url: {url}"
- )
-
- # Decode base64 string to bytes
- data_bytes = base64.b64decode(data_b64)
- return Part.from_bytes(data=data_bytes, mime_type=media_type)
- else:
- # For regular URLs, try to download the image and convert to bytes
- mime_type, _ = mimetypes.guess_type(url)
- if not mime_type or not mime_type.startswith("image/"):
- # Default to image/png if we can't detect or it's not an image type
- mime_type = "image/png"
- response = httpx.get(url).raise_for_status()
- data_bytes = response.content
- return Part.from_bytes(data=data_bytes, mime_type=mime_type)
-
-
-def _audio_url_part_to_google_genai(part: AudioURLPart) -> Part:
- """Convert an audio URL part to GoogleGenAI format."""
- url = part.audio_url.url
-
- # Handle data URLs
- if url.startswith("data:"):
- # data:[][;base64],
- res = url[5:].split(";base64,", 1)
- if len(res) != 2:
- raise ChatProviderError(f"Invalid data URL for audio: {url}")
-
- media_type, data_b64 = res
- # Supported audio formats for GoogleGenAI
- supported_audio_types = (
- "audio/wav",
- "audio/mp3",
- "audio/aiff",
- "audio/aac",
- "audio/ogg",
- "audio/flac",
- )
- if media_type not in supported_audio_types:
- error_msg = (
- f"Unsupported media type for base64 audio: {media_type}, url: {url}. "
- f"Supported types: {supported_audio_types}"
- )
- raise ChatProviderError(error_msg)
-
- # Decode base64 string to bytes
- data_bytes = base64.b64decode(data_b64)
- return Part.from_bytes(data=data_bytes, mime_type=media_type)
- else:
- # Fetch the audio and convert to bytes
- mime_type, _ = mimetypes.guess_type(url)
- if not mime_type or not mime_type.startswith("audio/"):
- # Default to audio/mp3 if we can't detect or it's not an audio type
- mime_type = "audio/mp3"
- response = httpx.get(url).raise_for_status()
- data_bytes = response.content
- return Part.from_bytes(data=data_bytes, mime_type=mime_type)
-
-
-def _tool_result_to_response_and_parts(
- parts: list[ContentPart],
-) -> tuple[dict[str, str], list[FunctionResponsePart]]:
- """Convert tool response content to Gemini function response format."""
- genai_parts: list[FunctionResponsePart] = []
- response: str = ""
-
- for part in parts:
- if isinstance(part, TextPart):
- if part.text:
- response += part.text
- elif isinstance(part, ImageURLPart):
- genai_parts.append(FunctionResponsePart.from_uri(file_uri=part.image_url.url))
- elif isinstance(part, AudioURLPart):
- genai_parts.append(FunctionResponsePart.from_uri(file_uri=part.audio_url.url))
- else:
- # Skip unsupported parts (like ThinkPart, etc.)
- continue
-
- return {"output": response}, genai_parts
-
-
-def _tool_call_id_to_name(tool_call_id: str, tool_name_by_id: dict[str, str]) -> str:
- """Resolve Gemini `FunctionResponse.name` from a tool_call_id."""
- if tool_call_id in tool_name_by_id:
- return tool_name_by_id[tool_call_id]
- # Fallback for older ids of the form "{tool_name}_{id}".
- return tool_call_id.split("_", 1)[0]
-
-
-def _tool_message_to_function_response_part(
- message: Message,
- *,
- tool_name_by_id: dict[str, str],
-) -> Part:
- if message.role != "tool": # pragma: no cover - defensive guard
- raise ChatProviderError("Expected a tool message.")
- if message.tool_call_id is None:
- raise ChatProviderError("Tool response is missing `tool_call_id`.")
-
- response_data, tool_result_parts = _tool_result_to_response_and_parts(message.content)
- return Part(
- function_response=FunctionResponse(
- id=message.tool_call_id,
- name=_tool_call_id_to_name(message.tool_call_id, tool_name_by_id),
- response=response_data,
- parts=tool_result_parts,
- )
- )
-
-
-def _tool_messages_to_google_genai_content(
- messages: Sequence[Message],
- *,
- tool_name_by_id: dict[str, str],
- expected_tool_call_ids: Sequence[str] | None = None,
- require_all_expected: bool = False,
-) -> Content:
- """Pack one-or-more tool results into a single Gemini "user" turn.
-
- VertexAI-backed Gemini enforces that, for a tool-calling turn, the next
- turn contains the same number of `functionResponse` parts as the preceding
- `functionCall` parts. Packing multiple tool results into a single "user"
- Content keeps us compliant and avoids ordering issues from parallel tool
- execution.
- """
- if not messages:
- raise ChatProviderError("Expected at least one tool message.")
-
- expected_index: dict[str, int] = (
- {tool_call_id: i for i, tool_call_id in enumerate(expected_tool_call_ids)}
- if expected_tool_call_ids is not None
- else {}
- )
- seen_tool_call_ids: set[str] = set()
- indexed_messages = list(enumerate(messages))
- indexed_messages.sort(
- key=lambda t: (expected_index.get(cast(str, t[1].tool_call_id), 10**9), t[0])
- )
-
- parts: list[Part] = []
- actual_tool_call_ids: list[str] = []
- for _, message in indexed_messages:
- if message.tool_call_id is None:
- raise ChatProviderError("Tool response is missing `tool_call_id`.")
- if message.tool_call_id in seen_tool_call_ids:
- raise ChatProviderError(f"Duplicate tool response for id: {message.tool_call_id}")
- seen_tool_call_ids.add(message.tool_call_id)
- actual_tool_call_ids.append(message.tool_call_id)
- parts.append(
- _tool_message_to_function_response_part(message, tool_name_by_id=tool_name_by_id)
- )
-
- if expected_tool_call_ids is not None and require_all_expected:
- expected_set = set(expected_tool_call_ids)
- missing = [
- tool_call_id
- for tool_call_id in expected_tool_call_ids
- if tool_call_id not in seen_tool_call_ids
- ]
- extra = [
- tool_call_id
- for tool_call_id in actual_tool_call_ids
- if tool_call_id not in expected_set
- ]
- if missing:
- raise ChatProviderError(f"Missing tool responses for ids: {missing}")
- if extra:
- raise ChatProviderError(f"Unexpected tool responses for ids: {extra}")
-
- return Content(role="user", parts=parts)
-
-
-def messages_to_google_genai_contents(messages: Sequence[Message]) -> list[Content]:
- """Convert internal messages into a Gemini contents list.
-
- Tool results for a tool-calling turn are packed into a single "user" message
- with N `functionResponse` parts matching the preceding "model" message's
- N `functionCall` parts. This avoids ordering issues from parallel tool
- execution and satisfies VertexAI's stricter validation.
- """
- contents: list[Content] = []
- tool_name_by_id: dict[str, str] = {}
-
- i = 0
- while i < len(messages):
- message = messages[i]
-
- if message.role == "assistant" and message.tool_calls:
- contents.append(message_to_google_genai(message))
- expected_tool_call_ids: list[str] = []
- for tool_call in message.tool_calls:
- tool_name_by_id[tool_call.id] = tool_call.function.name
- expected_tool_call_ids.append(tool_call.id)
-
- # Collect consecutive tool messages that correspond to this turn.
- j = i + 1
- tool_messages: list[Message] = []
- while j < len(messages) and messages[j].role == "tool":
- tool_messages.append(messages[j])
- j += 1
-
- if tool_messages:
- contents.append(
- _tool_messages_to_google_genai_content(
- tool_messages,
- tool_name_by_id=tool_name_by_id,
- expected_tool_call_ids=expected_tool_call_ids,
- require_all_expected=True,
- )
- )
- i = j
- continue
-
- i += 1
- continue
-
- if message.role == "tool":
- # Tool message without an immediately preceding tool-calling assistant
- # message (e.g. truncated history). Convert it best-effort.
- contents.append(
- _tool_messages_to_google_genai_content([message], tool_name_by_id=tool_name_by_id)
- )
- i += 1
- continue
-
- contents.append(message_to_google_genai(message))
- if message.role == "assistant" and message.tool_calls:
- for tool_call in message.tool_calls:
- tool_name_by_id[tool_call.id] = tool_call.function.name
- i += 1
-
- return contents
-
-
-def message_to_google_genai(message: Message) -> Content:
- """Convert a single internal message into GoogleGenAI wire format."""
- role = message.role
-
- if role == "tool":
- raise ChatProviderError(
- "Tool messages must be converted via messages_to_google_genai_contents "
- "to preserve tool-call ordering and tool-response packing."
- )
-
- # GoogleGenAI uses: "user" and "model" (not "assistant")
- google_genai_role = "model" if role == "assistant" else role
- parts: list[Part] = []
-
- # Handle content parts
- for part in message.content:
- if isinstance(part, TextPart):
- parts.append(Part.from_text(text=part.text))
- elif isinstance(part, ImageURLPart):
- parts.append(_image_url_part_to_google_genai(part))
- elif isinstance(part, AudioURLPart):
- parts.append(_audio_url_part_to_google_genai(part))
- elif isinstance(part, ThinkPart):
- # Note: skip part.thought because it is synthetic
- continue
- else:
- # Skip unsupported parts
- continue
-
- # Handle tool calls for assistant messages
- for tool_call in message.tool_calls or []:
- if tool_call.function.arguments:
- try:
- parsed_arguments = json.loads(tool_call.function.arguments)
- except json.JSONDecodeError as exc: # pragma: no cover - defensive guard
- raise ChatProviderError("Tool call arguments must be valid JSON.") from exc
- if not isinstance(parsed_arguments, dict):
- raise ChatProviderError("Tool call arguments must be a JSON object.")
- args = cast(dict[str, object], parsed_arguments)
- else:
- args = {}
-
- function_call = FunctionCall(
- id=tool_call.id,
- name=tool_call.function.name,
- args=args,
- )
- function_call_part = Part(function_call=function_call)
- # Add thought_signature back to function_call
- if tool_call.extras and "thought_signature_b64" in tool_call.extras:
- function_call_part.thought_signature = base64.b64decode(
- cast(str, tool_call.extras["thought_signature_b64"])
- )
- parts.append(function_call_part)
-
- return Content(role=google_genai_role, parts=parts)
-
-
-def _convert_error(error: Exception) -> ChatProviderError:
- """Convert a GoogleGenAI error to a Kosong chat provider error."""
- # Handle specific GoogleGenAI error types with detailed status code mapping
- if isinstance(error, genai_errors.ClientError):
- # 4xx client errors
- status_code = getattr(error, "code", 400)
- if status_code == 401:
- return APIStatusError(401, f"Authentication failed: {error}")
- elif status_code == 403:
- return APIStatusError(403, f"Permission denied: {error}")
- elif status_code == 429:
- return APIStatusError(429, f"Rate limit exceeded: {error}")
- return APIStatusError(status_code, str(error))
- elif isinstance(error, genai_errors.ServerError):
- # 5xx server errors
- status_code = getattr(error, "code", 500)
- return APIStatusError(status_code, f"Server error: {error}")
- elif isinstance(error, genai_errors.APIError):
- # Generic API errors
- status_code = getattr(error, "code", 500)
- return APIStatusError(status_code, str(error))
- elif isinstance(error, TimeoutError):
- return APITimeoutError(f"Request timed out: {error}")
- else:
- # Fallback for unexpected errors
- return ChatProviderError(f"Unexpected GoogleGenAI error: {error}")
-
-
-if __name__ == "__main__":
-
- async def main():
- import os
- from typing import override
-
- from pydantic import BaseModel
-
- import kosong
- from kosong.tooling import CallableTool2, ToolOk
- from kosong.tooling.simple import SimpleToolset
-
- chat = GoogleGenAI(
- model="gemini-3-pro-preview",
- vertexai=True,
- api_key=os.getenv("VERTEXAI_API_KEY"),
- ).with_thinking("high")
- system_prompt = "You are a helpful assistant."
-
- class GetWeatherParams(BaseModel):
- city: str
-
- class GetWeather(CallableTool2[GetWeatherParams]):
- name: str = "get_weather"
- description: str = "Get the weather of a city"
- params: type[GetWeatherParams] = GetWeatherParams
-
- @override
- async def __call__(self, params: GetWeatherParams) -> ToolReturnValue:
- return ToolOk(output="Sunny")
-
- toolset = SimpleToolset()
- toolset += GetWeather()
- history = [
- Message(
- role="user",
- content=(
- "What's the weather like in Beijing and Shanghai? "
- "Spawn parallel tool calls to get the answer."
- ),
- )
- ]
- result = await kosong.step(chat, system_prompt, toolset, history)
- tool_results = await result.tool_results()
-
- assistant_message = result.message
- tool_messages = [
- Message(role="tool", content=tr.return_value.output, tool_call_id=tr.tool_call_id)
- for tr in tool_results
- ]
- history.extend([assistant_message] + tool_messages)
-
- async for part in await chat.generate(system_prompt, toolset.tools, history):
- print(part.model_dump(exclude_none=True))
-
- import asyncio
-
- from dotenv import load_dotenv
-
- load_dotenv()
- asyncio.run(main())
diff --git a/src/kosong/contrib/chat_provider/openai_legacy.py b/src/kosong/contrib/chat_provider/openai_legacy.py
deleted file mode 100644
index 8cf5923..0000000
--- a/src/kosong/contrib/chat_provider/openai_legacy.py
+++ /dev/null
@@ -1,332 +0,0 @@
-import copy
-import uuid
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Any, Self, Unpack, cast
-
-import httpx
-from openai import AsyncOpenAI, AsyncStream, Omit, OpenAIError, omit
-from openai.types import CompletionUsage, ReasoningEffort
-from openai.types.chat import (
- ChatCompletion,
- ChatCompletionChunk,
- ChatCompletionMessageFunctionToolCall,
- ChatCompletionMessageParam,
-)
-from typing_extensions import TypedDict
-
-from kosong.chat_provider import ChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage
-from kosong.chat_provider.openai_common import (
- convert_error,
- thinking_effort_to_reasoning_effort,
- tool_to_openai,
-)
-from kosong.contrib.chat_provider.common import ToolMessageConversion
-from kosong.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(openai_legacy: "OpenAILegacy"):
- _: ChatProvider = openai_legacy
-
-
-class OpenAILegacy:
- """
- A chat provider that uses the OpenAI Chat Completions API.
-
- >>> chat_provider = OpenAILegacy(model="gpt-5", api_key="sk-1234567890")
- >>> chat_provider.name
- 'openai'
- >>> chat_provider.model_name
- 'gpt-5'
- """
-
- name = "openai"
-
- class GenerationKwargs(TypedDict, extra_items=Any, total=False):
- """
- Generation kwargs for various kinds of OpenAI-compatible APIs.
- `extra_items=Any` is used to support any extra args.
- """
-
- max_tokens: int | None
- temperature: float | None
- top_p: float | None
- n: int | None
- presence_penalty: float | None
- frequency_penalty: float | None
- stop: str | list[str] | None
- prompt_cache_key: str | None
-
- def __init__(
- self,
- *,
- model: str,
- api_key: str | None = None,
- base_url: str | None = None,
- stream: bool = True,
- reasoning_key: str | None = None,
- tool_message_conversion: ToolMessageConversion | None = None,
- **client_kwargs: Any,
- ):
- """
- Initialize the OpenAILegacy chat provider.
-
- To support OpenAI-compatible APIs that inject reasoning content in a extra field in
- the message, such as `{"reasoning": ...}`, `reasoning_key` can be set to the key name.
- """
- self.model = model
- self.stream = stream
- self.client = AsyncOpenAI(
- api_key=api_key,
- base_url=base_url,
- **client_kwargs,
- )
- """The underlying `AsyncOpenAI` client."""
- self._reasoning_effort: ReasoningEffort | Omit = omit
- self._reasoning_key = reasoning_key
- self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion
- self._generation_kwargs: OpenAILegacy.GenerationKwargs = {}
-
- @property
- def model_name(self) -> str:
- return self.model
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "OpenAILegacyStreamedMessage":
- messages: list[ChatCompletionMessageParam] = []
- if system_prompt:
- # `system` vs `developer`: see `message_to_openai` comments
- messages.append({"role": "system", "content": system_prompt})
- messages.extend(self._convert_message(message) for message in history)
-
- generation_kwargs: dict[str, Any] = {}
- generation_kwargs.update(self._generation_kwargs)
-
- try:
- response = await self.client.chat.completions.create(
- model=self.model,
- messages=messages,
- tools=(tool_to_openai(tool) for tool in tools),
- stream=self.stream,
- stream_options={"include_usage": True} if self.stream else omit,
- reasoning_effort=self._reasoning_effort,
- **generation_kwargs,
- )
- return OpenAILegacyStreamedMessage(response, self._reasoning_key)
- except (OpenAIError, httpx.HTTPError) as e:
- raise convert_error(e) from e
-
- def with_thinking(self, effort: ThinkingEffort) -> Self:
- new_self = copy.copy(self)
- new_self._reasoning_effort = thinking_effort_to_reasoning_effort(effort)
- return new_self
-
- def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
- """
- Copy the chat provider, updating the generation kwargs with the given values.
-
- Returns:
- Self: A new instance of the chat provider with updated generation kwargs.
- """
- new_self = copy.copy(self)
- new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
- new_self._generation_kwargs.update(kwargs)
- return new_self
-
- @property
- def model_parameters(self) -> dict[str, Any]:
- """
- The parameters of the model to use.
-
- For tracing/logging purposes.
- """
-
- model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)}
- if self._reasoning_effort is not omit:
- model_parameters["reasoning_effort"] = self._reasoning_effort
- return model_parameters
-
- def _convert_message(self, message: Message) -> ChatCompletionMessageParam:
- """Convert a Kosong message to OpenAI message."""
- # Note: for openai, `developer` role is more standard, but `system` is still accepted.
- # And many openai-compatible models do not accept `developer` role.
- # So we use `system` role here. OpenAIResponses will use `developer` role.
- # See https://cdn.openai.com/spec/model-spec-2024-05-08.html#definitions
- message = message.model_copy(deep=True)
- reasoning_content: str = ""
- content: list[ContentPart] = []
- for part in message.content:
- if isinstance(part, ThinkPart):
- reasoning_content += part.think
- else:
- content.append(part)
- # if tool message and `tool_result_conversion` is `extract_text`, patch all text parts into
- # one so that we can make use of the serialization process of `Message` to output string
- if message.role == "tool" and self._tool_message_conversion == "extract_text":
- message.content = [TextPart(text=message.extract_text(sep="\n"))]
- else:
- message.content = content
- dumped_message = message.model_dump(exclude_none=True)
- if reasoning_content:
- assert self._reasoning_key, (
- "reasoning_key must not be empty if reasoning_content exists"
- )
- dumped_message[self._reasoning_key] = reasoning_content
- return cast(ChatCompletionMessageParam, dumped_message)
-
-
-class OpenAILegacyStreamedMessage:
- def __init__(
- self, response: ChatCompletion | AsyncStream[ChatCompletionChunk], reasoning_key: str | None
- ):
- self._reasoning_key: str | None = reasoning_key
- if isinstance(response, ChatCompletion):
- self._iter = self._convert_non_stream_response(response)
- else:
- self._iter = self._convert_stream_response(response)
- self._id: str | None = None
- self._usage: CompletionUsage | None = None
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- @property
- def id(self) -> str | None:
- return self._id
-
- @property
- def usage(self) -> TokenUsage | None:
- if self._usage:
- cached = 0
- other_input = self._usage.prompt_tokens
- if (
- self._usage.prompt_tokens_details
- and self._usage.prompt_tokens_details.cached_tokens
- ):
- cached = self._usage.prompt_tokens_details.cached_tokens
- other_input -= cached
- return TokenUsage(
- input_other=other_input,
- output=self._usage.completion_tokens,
- input_cache_read=cached,
- )
- return None
-
- async def _convert_non_stream_response(
- self,
- response: ChatCompletion,
- ) -> AsyncIterator[StreamedMessagePart]:
- self._id = response.id
- self._usage = response.usage
- message = response.choices[0].message
- reasoning_key = self._reasoning_key
- if reasoning_key and (reasoning_content := getattr(message, reasoning_key, None)):
- assert isinstance(reasoning_content, str)
- yield ThinkPart(think=reasoning_content)
- if message.content:
- yield TextPart(text=message.content)
- if message.tool_calls:
- for tool_call in message.tool_calls:
- if isinstance(tool_call, ChatCompletionMessageFunctionToolCall):
- yield ToolCall(
- id=tool_call.id or str(uuid.uuid4()),
- function=ToolCall.FunctionBody(
- name=tool_call.function.name,
- arguments=tool_call.function.arguments,
- ),
- )
-
- async def _convert_stream_response(
- self,
- response: AsyncIterator[ChatCompletionChunk],
- ) -> AsyncIterator[StreamedMessagePart]:
- try:
- async for chunk in response:
- if chunk.id:
- self._id = chunk.id
- if chunk.usage:
- self._usage = chunk.usage
-
- if not chunk.choices:
- continue
-
- delta = chunk.choices[0].delta
-
- # convert thinking content
- reasoning_key = self._reasoning_key
- if reasoning_key and (reasoning_content := getattr(delta, reasoning_key, None)):
- assert isinstance(reasoning_content, str)
- yield ThinkPart(think=reasoning_content)
-
- # convert text content
- if delta.content:
- yield TextPart(text=delta.content)
-
- # convert tool calls
- for tool_call in delta.tool_calls or []:
- if not tool_call.function:
- continue
-
- if tool_call.function.name:
- yield ToolCall(
- id=tool_call.id or str(uuid.uuid4()),
- function=ToolCall.FunctionBody(
- name=tool_call.function.name,
- arguments=tool_call.function.arguments,
- ),
- )
- elif tool_call.function.arguments:
- yield ToolCallPart(
- arguments_part=tool_call.function.arguments,
- )
- else:
- # skip empty tool calls
- pass
- except (OpenAIError, httpx.HTTPError) as e:
- raise convert_error(e) from e
-
-
-if __name__ == "__main__":
-
- async def _dev_main():
- chat = OpenAILegacy(model="gpt-4o", stream=False)
- system_prompt = "You are a helpful assistant."
- history = [Message(role="user", content="Hello, how are you?")]
- async for part in await chat.generate(system_prompt, [], history):
- print(part.model_dump(exclude_none=True))
-
- tools = [
- Tool(
- name="get_weather",
- description="Get the weather",
- parameters={
- "type": "object",
- "properties": {
- "city": {
- "type": "string",
- "description": "The city to get the weather for.",
- },
- },
- },
- )
- ]
- history = [Message(role="user", content="What's the weather in Beijing?")]
- stream = await chat.generate(system_prompt, tools, history)
- async for part in stream:
- print(part.model_dump(exclude_none=True))
- print("usage:", stream.usage)
-
- import asyncio
-
- from dotenv import load_dotenv
-
- load_dotenv()
- asyncio.run(_dev_main())
diff --git a/src/kosong/contrib/chat_provider/openai_responses.py b/src/kosong/contrib/chat_provider/openai_responses.py
deleted file mode 100644
index 5db0de8..0000000
--- a/src/kosong/contrib/chat_provider/openai_responses.py
+++ /dev/null
@@ -1,567 +0,0 @@
-import copy
-import uuid
-from collections.abc import AsyncIterator, Sequence
-from typing import TYPE_CHECKING, Any, Self, TypedDict, Unpack, cast, get_args
-
-import httpx
-from openai import AsyncOpenAI, AsyncStream, OpenAIError
-from openai.types.responses import (
- Response,
- ResponseInputItemParam,
- ResponseInputParam,
- ResponseOutputMessageParam,
- ResponseOutputTextParam,
- ResponseReasoningItemParam,
- ResponseStreamEvent,
- ResponseUsage,
- ToolParam,
-)
-from openai.types.responses.response_function_call_output_item_list_param import (
- ResponseFunctionCallOutputItemListParam,
-)
-from openai.types.responses.response_input_file_content_param import (
- ResponseInputFileContentParam,
-)
-from openai.types.responses.response_input_file_param import ResponseInputFileParam
-from openai.types.responses.response_input_message_content_list_param import (
- ResponseInputMessageContentListParam,
-)
-from openai.types.shared.reasoning import Reasoning
-from openai.types.shared.reasoning_effort import ReasoningEffort
-from openai.types.shared_params.responses_model import ResponsesModel
-
-from kosong.chat_provider import ChatProvider, StreamedMessagePart, ThinkingEffort, TokenUsage
-from kosong.chat_provider.openai_common import convert_error, thinking_effort_to_reasoning_effort
-from kosong.contrib.chat_provider.common import ToolMessageConversion
-from kosong.message import (
- AudioURLPart,
- ContentPart,
- ImageURLPart,
- Message,
- TextPart,
- ThinkPart,
- ToolCall,
- ToolCallPart,
-)
-from kosong.tooling import Tool
-
-if TYPE_CHECKING:
-
- def type_check(openai_responses: "OpenAIResponses"):
- _: ChatProvider = openai_responses
-
-
-def get_openai_models_set() -> set[str]:
- """Return a set of all available OpenAI response models.
-
- This extracts all literal values from the ResponsesModel TypeAlias, which includes
- both ChatModel and additional response-specific models.
- """
- responses_model_args = get_args(ResponsesModel)
- # responses_model_args is (str, ChatModel, Literal[...])
- # Extract from ChatModel (index 1)
- chat_models = set(get_args(responses_model_args[1]))
- # Extract from the Literal part (index 2)
- response_models = set(get_args(responses_model_args[2]))
-
- return chat_models | response_models
-
-
-_openai_models = get_openai_models_set()
-
-
-def is_openai_model(model_name: str) -> bool:
- """Judge if the model name is an OpenAI model."""
- return model_name in _openai_models
-
-
-class OpenAIResponses:
- """
- A chat provider that uses the OpenAI Responses API.
-
- Similar to `OpenAILegacy`, but uses `client.responses` under the hood.
-
- This provider always enables reasoning when generating responses.
- If you want to use a non-reasoning model, please use `OpenAILegacy` instead.
-
- >>> chat_provider = OpenAIResponses(model="gpt-5-codex", api_key="sk-1234567890")
- >>> chat_provider.name
- 'openai-responses'
- >>> chat_provider.model_name
- 'gpt-5-codex'
- """
-
- name = "openai-responses"
-
- class GenerationKwargs(TypedDict, total=False):
- max_output_tokens: int | None
- max_tool_calls: int | None
- reasoning_effort: ReasoningEffort | None
- temperature: float | None
- top_logprobs: float | None
- top_p: float | None
- user: str | None
-
- def __init__(
- self,
- *,
- model: str,
- api_key: str | None = None,
- base_url: str | None = None,
- stream: bool = True,
- tool_message_conversion: ToolMessageConversion | None = None,
- **client_kwargs: Any,
- ):
- self._model = model
- self._stream = stream
- self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion
- self._client = AsyncOpenAI(
- api_key=api_key,
- base_url=base_url,
- **client_kwargs,
- )
- self._generation_kwargs: OpenAIResponses.GenerationKwargs = {}
-
- @property
- def model_name(self) -> str:
- return self._model
-
- async def generate(
- self,
- system_prompt: str,
- tools: Sequence[Tool],
- history: Sequence[Message],
- ) -> "OpenAIResponsesStreamedMessage":
- inputs: ResponseInputParam = []
- if system_prompt:
- system_message: ResponseInputItemParam = {"role": "system", "content": system_prompt}
- if is_openai_model(self.model_name):
- system_message["role"] = "developer"
- inputs.append(system_message)
- # The `Message` type is OpenAI-compatible for Responses API `input` messages.
-
- for message in history:
- inputs.extend(self._convert_message(message))
-
- generation_kwargs: dict[str, Any] = {}
- generation_kwargs.update(self._generation_kwargs)
- generation_kwargs["reasoning"] = Reasoning(
- effort=generation_kwargs.pop("reasoning_effort", None),
- summary="auto",
- )
- generation_kwargs["include"] = ["reasoning.encrypted_content"]
-
- try:
- response = await self._client.responses.create(
- stream=self._stream,
- model=self._model,
- input=inputs,
- tools=[_convert_tool(tool) for tool in tools],
- store=False,
- **generation_kwargs,
- )
- return OpenAIResponsesStreamedMessage(response)
- except (OpenAIError, httpx.HTTPError) as e:
- raise convert_error(e) from e
-
- def with_thinking(self, effort: ThinkingEffort) -> Self:
- reasoning_effort = thinking_effort_to_reasoning_effort(effort)
- return self.with_generation_kwargs(reasoning_effort=reasoning_effort)
-
- def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
- """
- Copy the chat provider, updating the generation kwargs with the given values.
-
- Returns:
- Self: A new instance of the chat provider with updated generation kwargs.
- """
- new_self = copy.copy(self)
- new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
- new_self._generation_kwargs.update(kwargs)
- return new_self
-
- @property
- def model_parameters(self) -> dict[str, Any]:
- """
- The parameters of the model to use.
-
- For tracing/logging purposes.
- """
-
- model_parameters: dict[str, Any] = {"base_url": str(self._client.base_url)}
- model_parameters.update(self._generation_kwargs)
- return model_parameters
-
- def _convert_message(self, message: Message) -> list[ResponseInputItemParam]:
- """Convert a single message to OpenAI Responses input format.
-
- Rules:
- - role in {user, assistant}: map to EasyInputMessageParam with role kept
- role == system: map to role=developer for OpenAI models, otherwise kept
- content: str kept; list[ContentPart] mapped to ResponseInputMessageContentListParam
- - role == tool: map to FunctionCallOutput with call_id and output
- """
-
- role = message.role
- if is_openai_model(self.model_name) and role == "system":
- role = "developer"
-
- # tool role → function_call_output (return value from a prior tool call)
- if role == "tool":
- call_id = message.tool_call_id or ""
- if self._tool_message_conversion == "extract_text":
- content = message.extract_text(sep="\n")
- else:
- content = message.content
- output = _message_content_to_function_output_items(content)
-
- return [
- {
- "call_id": call_id,
- "output": output,
- "type": "function_call_output",
- }
- ]
-
- result: list[ResponseInputItemParam] = []
-
- # user/system/assistant → message input item
- if len(message.content) > 0:
- # Split into two kinds of blocks: contiguous non-ThinkPart message blocks, and
- # contiguous ThinkPart groups (grouped by the same `encrypted` value)
- pending_parts: list[ContentPart] = []
-
- def flush_pending_parts() -> None:
- if not pending_parts:
- return
- if role == "assistant":
- # the "id" key is missing by purpose
- result.append(
- cast(
- ResponseOutputMessageParam,
- {
- "content": _content_parts_to_output_items(pending_parts),
- "role": role,
- "type": "message",
- },
- )
- )
- else:
- result.append(
- {
- "content": _content_parts_to_input_items(pending_parts),
- "role": role,
- "type": "message",
- }
- )
- pending_parts.clear()
-
- i = 0
- n = len(message.content)
- while i < n:
- part = message.content[i]
- if isinstance(part, ThinkPart):
- # Flush accumulated non-reasoning parts first
- flush_pending_parts()
- # Aggregate consecutive ThinkPart items with the same `encrypted` value
- encrypted_value = part.encrypted
- summaries = [{"type": "summary_text", "text": part.think or ""}]
- i += 1
- while i < n:
- next_part = message.content[i]
- if not isinstance(next_part, ThinkPart):
- break
- if next_part.encrypted != encrypted_value:
- break
- summaries.append({"type": "summary_text", "text": next_part.think or ""})
- i += 1
- result.append(
- cast(
- ResponseReasoningItemParam,
- {
- "summary": summaries,
- "type": "reasoning",
- "encrypted_content": encrypted_value,
- },
- )
- )
- else:
- pending_parts.append(part)
- i += 1
-
- # Handle remaining trailing non-reasoning parts
- flush_pending_parts()
-
- for tool_call in message.tool_calls or []:
- result.append(
- {
- "arguments": tool_call.function.arguments or "{}",
- "call_id": tool_call.id,
- "name": tool_call.function.name,
- "type": "function_call",
- }
- )
-
- return result
-
-
-def _convert_tool(tool: Tool) -> ToolParam:
- """Convert a Kosong tool to an OpenAI Responses tool."""
- return {
- "type": "function",
- "name": tool.name,
- "description": tool.description,
- "parameters": tool.parameters,
- "strict": False,
- }
-
-
-def _content_parts_to_input_items(parts: list[ContentPart]) -> ResponseInputMessageContentListParam:
- """Map internal ContentPart list → ResponseInputMessageContentListParam items."""
- items: ResponseInputMessageContentListParam = []
- for part in parts:
- if isinstance(part, TextPart):
- if part.text:
- items.append({"type": "input_text", "text": part.text})
- elif isinstance(part, ImageURLPart):
- # default detail
- url = part.image_url.url
- items.append(
- {
- "type": "input_image",
- "detail": "auto",
- "image_url": url,
- }
- )
- elif isinstance(part, AudioURLPart):
- mapped = _map_audio_url_to_input_item(part.audio_url.url)
- if mapped is not None:
- items.append(mapped)
- else:
- # Unknown content – ignore
- continue
- return items
-
-
-def _content_parts_to_output_items(parts: list[ContentPart]) -> list[ResponseOutputTextParam]:
- """Map internal ContentPart list → ResponseOutputTextParam list items."""
- items: list[ResponseOutputTextParam] = []
- for part in parts:
- if isinstance(part, TextPart):
- if part.text:
- items.append({"type": "output_text", "text": part.text, "annotations": []})
- else:
- # Unknown content – ignore
- continue
- return items
-
-
-def _message_content_to_function_output_items(
- content: str | list[ContentPart],
-) -> str | ResponseFunctionCallOutputItemListParam:
- """Map ContentPart list → ResponseFunctionCallOutputItemListParam items."""
- output: str | ResponseFunctionCallOutputItemListParam
- # If tool_result_process is `extract_text`, patch all text parts into one string
- if isinstance(content, str):
- output = content
- else:
- items: ResponseFunctionCallOutputItemListParam = []
- for part in content:
- if isinstance(part, TextPart):
- if part.text:
- items.append({"type": "input_text", "text": part.text})
- elif isinstance(part, ImageURLPart):
- url = part.image_url.url
- items.append({"type": "input_image", "image_url": url})
- elif isinstance(part, AudioURLPart):
- mapped = _map_audio_url_to_file_content(part.audio_url.url)
- if mapped is not None:
- items.append(mapped)
- else:
- continue
- output = items
- return output
-
-
-def _map_audio_url_to_input_item(url: str) -> ResponseInputFileParam | None:
- """Map audio URL/data URI to an input content item (always an input_file).
-
- OpenAI Responses message content no longer accepts `input_audio`, so both inline
- data and remote URLs are converted to `input_file` items instead.
- """
- if url.startswith("data:audio/"):
- try:
- header, b64 = url.split(",", 1)
- subtype = header.split("/")[1].split(";")[0].lower()
- ext = "mp3" if subtype in {"mp3", "mpeg"} else ("wav" if subtype == "wav" else None)
- if ext is None:
- return None
- item: ResponseInputFileParam = {"type": "input_file", "file_data": b64}
- item["filename"] = f"inline.{ext}"
- return item
- except Exception:
- return None
- if url.startswith("http://") or url.startswith("https://"):
- return {"type": "input_file", "file_url": url}
- return None
-
-
-def _map_audio_url_to_file_content(url: str) -> ResponseInputFileContentParam | None:
- """Map audio URL/data URI to a file content item for function_call_output."""
- if url.startswith("http://") or url.startswith("https://"):
- return {"type": "input_file", "file_url": url}
- if url.startswith("data:audio/"):
- try:
- _, b64 = url.split(",", 1)
- # We can attach filename optionally; Responses accepts file_data only
- return {"type": "input_file", "file_data": b64}
- except Exception:
- return None
- return None
-
-
-class OpenAIResponsesStreamedMessage:
- def __init__(self, response: Response | AsyncStream[ResponseStreamEvent]):
- if isinstance(response, Response):
- self._iter = self._convert_non_stream_response(response)
- else:
- self._iter = self._convert_stream_response(response)
- self._id: str | None = None
- self._usage: ResponseUsage | None = None
-
- def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
- return self
-
- async def __anext__(self) -> StreamedMessagePart:
- return await self._iter.__anext__()
-
- @property
- def id(self) -> str | None:
- return self._id
-
- @property
- def usage(self) -> TokenUsage | None:
- if self._usage:
- cached = 0
- other_input = self._usage.input_tokens
- if self._usage.input_tokens_details and self._usage.input_tokens_details.cached_tokens:
- cached = self._usage.input_tokens_details.cached_tokens
- other_input -= cached
- return TokenUsage(
- input_other=other_input,
- output=self._usage.output_tokens,
- input_cache_read=cached,
- )
- return None
-
- async def _convert_non_stream_response(
- self, response: Response
- ) -> AsyncIterator[StreamedMessagePart]:
- """Convert a non-streaming Responses API result into message parts."""
- self._id = response.id
- self._usage = response.usage
- for item in response.output:
- if item.type == "message":
- for content in item.content:
- if content.type == "output_text":
- yield TextPart(text=content.text)
- elif item.type == "function_call":
- yield ToolCall(
- id=item.call_id or str(uuid.uuid4()),
- function=ToolCall.FunctionBody(
- name=item.name,
- arguments=item.arguments,
- ),
- )
- elif item.type == "reasoning":
- for summary in item.summary:
- yield ThinkPart(
- think=summary.text,
- encrypted=item.encrypted_content,
- )
-
- async def _convert_stream_response(
- self, response: AsyncStream[ResponseStreamEvent]
- ) -> AsyncIterator[StreamedMessagePart]:
- """Convert streaming Responses events into message parts."""
- try:
- async for chunk in response:
- if chunk.type == "response.output_text.delta":
- yield TextPart(text=chunk.delta)
- elif chunk.type == "response.output_item.added":
- item = chunk.item
- self._id = item.id
- if item.type == "function_call":
- yield ToolCall(
- id=item.call_id or str(uuid.uuid4()),
- function=ToolCall.FunctionBody(
- name=item.name,
- arguments=item.arguments,
- ),
- )
- elif chunk.type == "response.output_item.done":
- item = chunk.item
- self._id = item.id
- if item.type == "reasoning":
- yield ThinkPart(think="", encrypted=item.encrypted_content)
- elif chunk.type == "response.function_call_arguments.delta":
- yield ToolCallPart(arguments_part=chunk.delta)
- elif chunk.type == "response.reasoning_summary_part.added":
- yield ThinkPart(think="")
- elif chunk.type == "response.reasoning_summary_text.delta":
- yield ThinkPart(think=chunk.delta)
- elif chunk.type == "response.completed":
- self._usage = chunk.response.usage
- except (OpenAIError, httpx.HTTPError) as e:
- raise convert_error(e) from e
-
-
-if __name__ == "__main__":
-
- async def _dev_main():
- # Non-streaming example
- chat = OpenAIResponses(model="gpt-5-codex", stream=True)
- system_prompt = "You are a helpful assistant."
- history = [Message(role="user", content="Hello, how are you?")]
-
- from kosong import generate
-
- result = await generate(chat, system_prompt, [], history)
- print(result.message)
- print(result.usage)
- history.append(result.message)
-
- # Streaming example with tools
- tools = [
- Tool(
- name="get_weather",
- description="Get the weather",
- parameters={
- "type": "object",
- "properties": {
- "city": {
- "type": "string",
- "description": "The city to get the weather for.",
- },
- },
- },
- )
- ]
- history.append(Message(role="user", content="What's the weather in Beijing?"))
- result = await generate(chat, system_prompt, tools, history)
- print(result.message)
- print(result.usage)
- history.append(result.message)
- for tool_call in result.message.tool_calls or []:
- assert tool_call.function.name == "get_weather"
- history.append(Message(role="tool", tool_call_id=tool_call.id, content="Sunny"))
- result = await generate(chat, system_prompt, tools, history)
- print(result.message)
- print(result.usage)
-
- import asyncio
-
- from dotenv import load_dotenv
-
- load_dotenv(override=True)
- asyncio.run(_dev_main())
diff --git a/src/kosong/contrib/context/__init__.py b/src/kosong/contrib/context/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/kosong/contrib/context/linear.py b/src/kosong/contrib/context/linear.py
deleted file mode 100644
index 3176c1c..0000000
--- a/src/kosong/contrib/context/linear.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import asyncio
-import json
-from pathlib import Path
-from typing import IO, Protocol, runtime_checkable
-
-from kosong.message import Message
-
-
-class LinearContext:
- """
- A context that contains a linear history of messages.
- """
-
- def __init__(self, storage: "LinearStorage"):
- self._storage = storage
-
- @property
- def history(self) -> list[Message]:
- return self._storage.messages
-
- @property
- def token_count(self) -> int:
- return self._storage.token_count
-
- async def add_message(self, message: Message):
- await self._storage.append_message(message)
-
- async def mark_token_count(self, token_count: int):
- await self._storage.mark_token_count(token_count)
-
-
-@runtime_checkable
-class LinearStorage(Protocol):
- @property
- def messages(self) -> list[Message]:
- """
- All messages in the storage.
- """
- ...
-
- @property
- def token_count(self) -> int:
- """
- The total token count of the messages in the storage.
- This may not be the precise token count, depending on the caller of `mark_token_count`.
- """
- ...
-
- async def append_message(self, message: Message) -> None: ...
- async def mark_token_count(self, token_count: int) -> None: ...
-
-
-class MemoryLinearStorage:
- """
- A linear storage that stores messages in memory, only for testing.
- """
-
- def __init__(self):
- self._messages: list[Message] = []
- self._token_count: int | None = None
-
- @property
- def messages(self) -> list[Message]:
- return self._messages
-
- @property
- def token_count(self) -> int:
- return self._token_count or 0
-
- async def append_message(self, message: Message):
- self._messages.append(message)
-
- async def mark_token_count(self, token_count: int):
- self._token_count = token_count
-
-
-class JsonlLinearStorage(MemoryLinearStorage):
- """
- A linear storage that stores messages in a JSONL file.
- """
-
- def __init__(self, path: Path | str):
- super().__init__()
- self._path = path if isinstance(path, Path) else Path(path)
- self._file: IO[str] | None = None
-
- async def restore(self):
- """Restore all messages from the JSONL file."""
- if self._messages:
- raise RuntimeError("The storage is already modified")
- if not self._path.exists():
- return
-
- def _restore():
- with open(self._path, encoding="utf-8") as f:
- for line in f:
- if not line.strip():
- continue
- line_json = json.loads(line)
- if "token_count" in line_json:
- self._token_count = line_json["token_count"]
- continue
- message = Message.model_validate(line_json)
- self._messages.append(message)
-
- await asyncio.to_thread(_restore)
-
- def _get_file(self) -> IO[str]:
- if self._file is None:
- self._file = open(self._path, "a", encoding="utf-8") # noqa: SIM115
- return self._file
-
- def __del__(self):
- if self._file:
- self._file.close()
-
- async def append_message(self, message: Message):
- await super().append_message(message)
-
- def _write():
- file = self._get_file()
- json.dump(
- message.model_dump(exclude_none=True),
- file,
- ensure_ascii=False,
- separators=(",", ":"),
- )
- file.write("\n")
-
- await asyncio.to_thread(_write)
-
- async def mark_token_count(self, token_count: int):
- await super().mark_token_count(token_count)
-
- def _write():
- file = self._get_file()
- json.dump(
- {"role": "_usage", "token_count": token_count},
- file,
- ensure_ascii=False,
- separators=(",", ":"),
- )
- file.write("\n")
-
- await asyncio.to_thread(_write)
diff --git a/src/kosong/message.py b/src/kosong/message.py
deleted file mode 100644
index badb409..0000000
--- a/src/kosong/message.py
+++ /dev/null
@@ -1,274 +0,0 @@
-from abc import ABC
-from typing import Any, ClassVar, Literal, cast, override
-
-from pydantic import BaseModel, GetCoreSchemaHandler, field_serializer, field_validator
-from pydantic_core import core_schema
-
-from kosong.utils.typing import JsonType
-
-
-class MergeableMixin:
- def merge_in_place(self, other: Any) -> bool:
- """Merge the other part into the current part. Return True if the merge is successful."""
- return False
-
-
-class ContentPart(BaseModel, ABC, MergeableMixin):
- """A part of a message content."""
-
- __content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
-
- type: str
- ... # to be added by subclasses
-
- def __init_subclass__(cls, **kwargs: Any) -> None:
- super().__init_subclass__(**kwargs)
-
- invalid_subclass_error_msg = (
- f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`"
- )
-
- type_value = getattr(cls, "type", None)
- if type_value is None or not isinstance(type_value, str):
- raise ValueError(invalid_subclass_error_msg)
-
- cls.__content_part_registry[type_value] = cls
-
- @classmethod
- def __get_pydantic_core_schema__(
- cls, source_type: Any, handler: GetCoreSchemaHandler
- ) -> core_schema.CoreSchema:
- # If we're dealing with the base ContentPart class, use custom validation
- if cls.__name__ == "ContentPart":
-
- def validate_content_part(value: Any) -> Any:
- # if it's already an instance of a ContentPart subclass, return it
- if hasattr(value, "__class__") and issubclass(value.__class__, cls):
- return value
-
- # if it's a dict with a type field, dispatch to the appropriate subclass
- if isinstance(value, dict) and "type" in value:
- type_value: Any | None = cast(dict[str, Any], value).get("type")
- if not isinstance(type_value, str):
- raise ValueError(f"Cannot validate {value} as ContentPart")
- target_class = cls.__content_part_registry[type_value]
- return target_class.model_validate(value)
-
- raise ValueError(f"Cannot validate {value} as ContentPart")
-
- return core_schema.no_info_plain_validator_function(validate_content_part)
-
- # for subclasses, use the default schema
- return handler(source_type)
-
-
-class TextPart(ContentPart):
- """
- >>> TextPart(text="Hello, world!").model_dump()
- {'type': 'text', 'text': 'Hello, world!'}
- """
-
- type: str = "text"
- text: str
-
- @override
- def merge_in_place(self, other: Any) -> bool:
- if not isinstance(other, TextPart):
- return False
- self.text += other.text
- return True
-
-
-class ThinkPart(ContentPart):
- """
- >>> ThinkPart(think="I think I need to think about this.").model_dump()
- {'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
- """
-
- type: str = "think"
- think: str
- encrypted: str | None = None
- """Encrypted thinking content, or signature."""
-
- @override
- def merge_in_place(self, other: Any) -> bool:
- if not isinstance(other, ThinkPart):
- return False
- if self.encrypted:
- return False
- self.think += other.think
- if other.encrypted:
- self.encrypted = other.encrypted
- return True
-
-
-class ImageURLPart(ContentPart):
- """
- >>> ImageURLPart(
- ... image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")
- ... ).model_dump()
- {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png', 'id': None}}
- """
-
- class ImageURL(BaseModel):
- """Image URL payload."""
-
- url: str
- """The URL of the image, can be data URI scheme like `data:image/png;base64,...`."""
- id: str | None = None
- """The ID of the image, to allow LLMs to distinguish different images."""
-
- type: str = "image_url"
- image_url: ImageURL
-
-
-class AudioURLPart(ContentPart):
- """
- >>> AudioURLPart(
- ... audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")
- ... ).model_dump()
- {'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
- """
-
- class AudioURL(BaseModel):
- """Audio URL payload."""
-
- url: str
- """The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`."""
- id: str | None = None
- """The ID of the audio, to allow LLMs to distinguish different audios."""
-
- type: str = "audio_url"
- audio_url: AudioURL
-
-
-class ToolCall(BaseModel, MergeableMixin):
- """
- A tool call requested by the assistant.
-
- >>> ToolCall(
- ... id="123",
- ... function=ToolCall.FunctionBody(name="function", arguments="{}"),
- ... ).model_dump(exclude_none=True)
- {'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}}
- """
-
- class FunctionBody(BaseModel):
- """Tool call function body."""
-
- name: str
- """The name of the tool to be called."""
- arguments: str | None
- """Arguments of the tool call in JSON string format."""
-
- type: Literal["function"] = "function"
-
- id: str
- """The ID of the tool call."""
- function: FunctionBody
- """The function body of the tool call."""
- extras: dict[str, JsonType] | None = None
- """Extra information about the tool call."""
-
- @override
- def merge_in_place(self, other: Any) -> bool:
- if not isinstance(other, ToolCallPart):
- return False
- if self.function.arguments is None:
- self.function.arguments = other.arguments_part
- else:
- self.function.arguments += other.arguments_part or ""
- return True
-
-
-class ToolCallPart(BaseModel, MergeableMixin):
- """A part of the tool call."""
-
- arguments_part: str | None = None
- """A part of the arguments of the tool call."""
-
- @override
- def merge_in_place(self, other: Any) -> bool:
- if not isinstance(other, ToolCallPart):
- return False
- if self.arguments_part is None:
- self.arguments_part = other.arguments_part
- else:
- self.arguments_part += other.arguments_part or ""
- return True
-
-
-type Role = Literal[
- # for OpenAI API, this should be converted to `developer`
- # OpenAI & Kimi support system messages in the middle of the conversation.
- # Anthropic only support system messages at the beginning https://docs.claude.com/en/api/messages#body-messages
- # In this case, we map `system` message to a `user` message wrapped in `` tags.
- "system",
- "user",
- "assistant",
- "tool",
-]
-"""The role of a message sender."""
-
-
-class Message(BaseModel):
- """A message in a conversation."""
-
- role: Role
- """The role of the message sender."""
-
- name: str | None = None
-
- content: list[ContentPart]
- """
- The content of the message.
- Empty list `[]` will be interpreted as no content.
- """
-
- tool_calls: list[ToolCall] | None = None
- """Tool calls requested by the assistant in this message."""
-
- tool_call_id: str | None = None
- """The ID of the tool call if this message is a tool response."""
-
- partial: bool | None = None
-
- @field_serializer("content")
- def _serialize_content(self, content: list[ContentPart]) -> str | list[dict[str, Any]] | None:
- if len(content) == 1 and isinstance(content[0], TextPart):
- return content[0].text
- return [part.model_dump() for part in content]
-
- @field_validator("content", mode="before")
- @classmethod
- def _coerce_none_content(cls, value: Any) -> Any:
- if value is None:
- return []
- if isinstance(value, str):
- return [TextPart(text=value)]
- return value
-
- def __init__(
- self,
- *,
- role: Role,
- content: list[ContentPart] | ContentPart | str,
- tool_calls: list[ToolCall] | None = None,
- tool_call_id: str | None = None,
- **data: Any,
- ) -> None:
- if isinstance(content, str):
- content = [TextPart(text=content)]
- elif isinstance(content, ContentPart):
- content = [content]
- super().__init__(
- role=role,
- content=content,
- tool_calls=tool_calls,
- tool_call_id=tool_call_id,
- **data,
- )
-
- def extract_text(self, sep: str = "") -> str:
- """Extract and concatenate all text parts in the message content."""
- return sep.join(part.text for part in self.content if isinstance(part, TextPart))
diff --git a/src/kosong/py.typed b/src/kosong/py.typed
deleted file mode 100644
index e69de29..0000000
diff --git a/src/kosong/tooling/__init__.py b/src/kosong/tooling/__init__.py
deleted file mode 100644
index 94cf1b6..0000000
--- a/src/kosong/tooling/__init__.py
+++ /dev/null
@@ -1,351 +0,0 @@
-from abc import ABC, abstractmethod
-from asyncio import Future
-from typing import Any, ClassVar, Protocol, Self, cast, override, runtime_checkable
-
-import jsonschema
-import pydantic
-from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
-from pydantic.json_schema import GenerateJsonSchema
-from pydantic_core import core_schema
-
-from kosong.message import ContentPart, ToolCall
-from kosong.utils.jsonschema import deref_json_schema
-from kosong.utils.typing import JsonType
-
-type ParametersType = dict[str, Any]
-
-
-class Tool(BaseModel):
- """The definition of a tool that can be recognized by the model."""
-
- name: str
- """The name of the tool."""
-
- description: str
- """The description of the tool."""
-
- parameters: ParametersType
- """The parameters of the tool, in JSON Schema format."""
-
- @model_validator(mode="after")
- def _validate_parameters(self) -> Self:
- jsonschema.validate(self.parameters, jsonschema.Draft202012Validator.META_SCHEMA)
- return self
-
-
-class DisplayBlock(BaseModel, ABC):
- """
- A block of content to be displayed to the user.
-
- Similar to ContentPart, but scoped to tool return display payloads (user-facing UI).
- ContentPart is for model-facing message content; DisplayBlock is for tool/UI extensions.
- """
-
- __display_block_registry: ClassVar[dict[str, type["DisplayBlock"]]] = {}
-
- type: str
- ... # to be added by subclasses
-
- def __init_subclass__(cls, **kwargs: Any) -> None:
- super().__init_subclass__(**kwargs)
-
- invalid_subclass_error_msg = (
- f"DisplayBlock subclass {cls.__name__} must have a `type` field of type `str`"
- )
-
- type_value = getattr(cls, "type", None)
- if type_value is None or not isinstance(type_value, str):
- raise ValueError(invalid_subclass_error_msg)
-
- cls.__display_block_registry[type_value] = cls
-
- @classmethod
- def __get_pydantic_core_schema__(
- cls, source_type: Any, handler: GetCoreSchemaHandler
- ) -> core_schema.CoreSchema:
- # If we're dealing with the base DisplayBlock class, use custom validation
- if cls.__name__ == "DisplayBlock":
-
- def validate_display_block(value: Any) -> Any:
- # if it's already an instance of a DisplayBlock subclass, return it
- if hasattr(value, "__class__") and issubclass(value.__class__, cls):
- return value
-
- # if it's a dict with a type field, dispatch to the appropriate subclass
- if isinstance(value, dict) and "type" in value:
- type_value: Any | None = cast(dict[str, Any], value).get("type")
- if not isinstance(type_value, str):
- raise ValueError(f"Cannot validate {value} as DisplayBlock")
- target_class = cls.__display_block_registry.get(type_value)
- if target_class is None:
- data = {k: v for k, v in cast(dict[str, Any], value).items() if k != "type"}
- return UnknownDisplayBlock.model_validate(
- {"type": type_value, "data": data}
- )
- return target_class.model_validate(value)
-
- raise ValueError(f"Cannot validate {value} as DisplayBlock")
-
- return core_schema.no_info_plain_validator_function(validate_display_block)
-
- # for subclasses, use the default schema
- return handler(source_type)
-
-
-class UnknownDisplayBlock(DisplayBlock):
- """Fallback display block for unknown types."""
-
- type: str = "unknown"
- data: JsonType
-
-
-class BriefDisplayBlock(DisplayBlock):
- """A brief display block with plain string content."""
-
- type: str = "brief"
- text: str
-
-
-class ToolReturnValue(BaseModel):
- """The return type of a callable tool."""
-
- is_error: bool
- """Whether the tool call resulted in an error."""
-
- # For model
- output: str | list[ContentPart]
- """The output content returned by the tool."""
- message: str
- """An explanatory message to be given to the model."""
-
- # For user
- display: list[DisplayBlock]
- """The content blocks to be displayed to the user."""
-
- # For debugging/testing
- extras: dict[str, JsonType] | None = None
-
- @property
- def brief(self) -> str:
- """Get the brief display block data, if any."""
- for block in self.display:
- if isinstance(block, BriefDisplayBlock):
- return block.text
- return ""
-
-
-class ToolOk(ToolReturnValue):
- """Subclass of `ToolReturnValue` representing a successful tool call."""
-
- def __init__(
- self,
- *,
- output: str | ContentPart | list[ContentPart],
- message: str = "",
- brief: str = "",
- ) -> None:
- super().__init__(
- is_error=False,
- output=([output] if isinstance(output, ContentPart) else output),
- message=message,
- display=[BriefDisplayBlock(text=brief)] if brief else [],
- )
-
-
-class ToolError(ToolReturnValue):
- """Subclass of `ToolReturnValue` representing a failed tool call."""
-
- def __init__(
- self, *, message: str, brief: str, output: str | ContentPart | list[ContentPart] = ""
- ):
- super().__init__(
- is_error=True,
- output=([output] if isinstance(output, ContentPart) else output),
- message=message,
- display=[BriefDisplayBlock(text=brief)] if brief else [],
- )
-
-
-class CallableTool(Tool, ABC):
- """
- The abstract base class of tools that can be called as callables.
-
- The tool will be called with the arguments provided in the `ToolCall`.
- If the arguments are given as a JSON array, it will be unpacked into positional arguments.
- If the arguments are given as a JSON object, it will be unpacked into keyword arguments.
- Otherwise, the arguments will be passed as a single argument.
- """
-
- @property
- def base(self) -> Tool:
- """The base tool definition."""
- return self
-
- async def call(self, arguments: JsonType) -> ToolReturnValue:
- from kosong.tooling.error import ToolValidateError
-
- try:
- jsonschema.validate(arguments, self.parameters)
- except jsonschema.ValidationError as e:
- return ToolValidateError(str(e))
-
- if isinstance(arguments, list):
- ret = await self.__call__(*arguments)
- elif isinstance(arguments, dict):
- ret = await self.__call__(**arguments)
- else:
- ret = await self.__call__(arguments)
- if not isinstance(ret, ToolReturnValue): # pyright: ignore[reportUnnecessaryIsInstance]
- # let's do not trust the return type of the tool
- ret = ToolError(
- message=f"Invalid return type: {type(ret)}",
- brief="Invalid return type",
- )
- return ret
-
- @abstractmethod
- async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:
- """
- @public
-
- The implementation of the callable tool.
- """
- ...
-
-
-class _GenerateJsonSchemaNoTitles(GenerateJsonSchema):
- """Custom JSON schema generator that omits titles."""
-
- @override
- def field_title_should_be_set(self, schema) -> bool: # pyright: ignore[reportMissingParameterType]
- return False
-
- @override
- def _update_class_schema(self, json_schema, cls, config) -> None: # pyright: ignore[reportMissingParameterType]
- super()._update_class_schema(json_schema, cls, config)
- json_schema.pop("title", None)
-
-
-class CallableTool2[Params: BaseModel](ABC):
- """
- The abstract base class of tools that can be called as callables, with typed parameters.
-
- The tool will be called with the arguments provided in the `ToolCall`.
- The arguments must be a JSON object, and will be validated by Pydantic to the `Params` type.
- """
-
- name: str
- """The name of the tool."""
- description: str
- """The description of the tool."""
- params: type[Params]
- """The Pydantic model type of the tool parameters."""
-
- def __init__(
- self,
- name: str | None = None,
- description: str | None = None,
- params: type[Params] | None = None,
- ) -> None:
- cls = self.__class__
-
- self.name = name or getattr(cls, "name", "")
- if not self.name:
- raise ValueError(
- "Tool name must be provided either as class variable or constructor argument"
- )
- if not isinstance(self.name, str): # pyright: ignore[reportUnnecessaryIsInstance]
- raise ValueError("Tool name must be a string")
-
- self.description = description or getattr(cls, "description", "")
- if not self.description:
- raise ValueError(
- "Tool description must be provided either as class variable or constructor argument"
- )
- if not isinstance(self.description, str): # pyright: ignore[reportUnnecessaryIsInstance]
- raise ValueError("Tool description must be a string")
-
- self.params = params or getattr(cls, "params", None) # type: ignore
- if not self.params:
- raise ValueError(
- "Tool param must be provided either as class variable or constructor argument"
- )
- if not isinstance(self.params, type) or not issubclass(self.params, BaseModel): # pyright: ignore[reportUnnecessaryIsInstance]
- raise ValueError("Tool params must be a subclass of pydantic.BaseModel")
-
- self._base = Tool(
- name=self.name,
- description=self.description,
- parameters=deref_json_schema(
- self.params.model_json_schema(schema_generator=_GenerateJsonSchemaNoTitles)
- ),
- )
-
- @property
- def base(self) -> Tool:
- """The base tool definition."""
- return self._base
-
- async def call(self, arguments: JsonType) -> ToolReturnValue:
- from kosong.tooling.error import ToolValidateError
-
- try:
- params = self.params.model_validate(arguments)
- except pydantic.ValidationError as e:
- return ToolValidateError(str(e))
-
- ret = await self.__call__(params)
- if not isinstance(ret, ToolReturnValue): # pyright: ignore[reportUnnecessaryIsInstance]
- # let's do not trust the return type of the tool
- ret = ToolError(
- message=f"Invalid return type: {type(ret)}",
- brief="Invalid return type",
- )
- return ret
-
- @abstractmethod
- async def __call__(self, params: Params) -> ToolReturnValue:
- """
- @public
-
- The implementation of the callable tool.
- """
- ...
-
-
-class ToolResult(BaseModel):
- """The result of a tool call."""
-
- tool_call_id: str
- """The ID of the tool call."""
- return_value: ToolReturnValue
- """The actual return value of the tool call."""
-
-
-ToolResultFuture = Future[ToolResult]
-type HandleResult = ToolResultFuture | ToolResult
-
-
-@runtime_checkable
-class Toolset(Protocol):
- """
- The interface of toolsets that can register tools and handle tool calls.
- """
-
- @property
- def tools(self) -> list[Tool]:
- """The list of tool definitions registered in this toolset."""
- ...
-
- def handle(self, tool_call: ToolCall) -> HandleResult:
- """
- Handle a tool call.
- The result of the tool call, or the async future of the result, should be returned.
- The result should be a `ToolReturnValue`.
-
- This method MUST NOT do any blocking operations because it will be called during
- consuming the chat response stream.
- This method MUST NOT raise any exception except for `asyncio.CancelledError`. Any other
- error should be returned as a `ToolReturnValue` with `is_error=True`.
- """
- ...
diff --git a/src/kosong/tooling/empty.py b/src/kosong/tooling/empty.py
deleted file mode 100644
index eb0374f..0000000
--- a/src/kosong/tooling/empty.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from typing import TYPE_CHECKING
-
-from kosong.message import ToolCall
-from kosong.tooling import HandleResult, Tool, ToolResult, Toolset
-from kosong.tooling.error import ToolNotFoundError
-
-if TYPE_CHECKING:
-
- def type_check(empty: "EmptyToolset"):
- _: Toolset = empty
-
-
-class EmptyToolset:
- """A toolset implementation that always contains no tools."""
-
- @property
- def tools(self) -> list[Tool]:
- return []
-
- def handle(self, tool_call: ToolCall) -> HandleResult:
- return ToolResult(
- tool_call_id=tool_call.id,
- return_value=ToolNotFoundError(tool_call.function.name),
- )
diff --git a/src/kosong/tooling/error.py b/src/kosong/tooling/error.py
deleted file mode 100644
index d18c93a..0000000
--- a/src/kosong/tooling/error.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from kosong.tooling import ToolError
-
-
-class ToolNotFoundError(ToolError):
- """The tool was not found."""
-
- def __init__(self, tool_name: str):
- super().__init__(
- message=f"Tool `{tool_name}` not found",
- brief=f"Tool `{tool_name}` not found",
- )
-
-
-class ToolParseError(ToolError):
- """The arguments of the tool are not valid JSON."""
-
- def __init__(self, message: str):
- super().__init__(
- message=f"Error parsing JSON arguments: {message}",
- brief="Invalid arguments",
- )
-
-
-class ToolValidateError(ToolError):
- """The arguments of the tool are not valid."""
-
- def __init__(self, message: str):
- super().__init__(
- message=f"Error validating JSON arguments: {message}",
- brief="Invalid arguments",
- )
-
-
-class ToolRuntimeError(ToolError):
- """The tool failed to run."""
-
- def __init__(self, message: str):
- super().__init__(
- message=f"Error running tool: {message}",
- brief="Tool runtime error",
- )
diff --git a/src/kosong/tooling/mcp.py b/src/kosong/tooling/mcp.py
deleted file mode 100644
index e8addc2..0000000
--- a/src/kosong/tooling/mcp.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import mcp.types
-
-import kosong.message
-
-
-def convert_mcp_content(part: mcp.types.ContentBlock) -> kosong.message.ContentPart:
- """Convert MCP content block to kosong message content part.
-
- Raises:
- ValueError: If the content type or mime type is not supported.
- """
- match part:
- case mcp.types.TextContent(text=text):
- return kosong.message.TextPart(text=text)
- case mcp.types.ImageContent(data=data, mimeType=mimeType):
- return kosong.message.ImageURLPart(
- image_url=kosong.message.ImageURLPart.ImageURL(url=f"data:{mimeType};base64,{data}")
- )
-
- case mcp.types.AudioContent(data=data, mimeType=mimeType):
- return kosong.message.AudioURLPart(
- audio_url=kosong.message.AudioURLPart.AudioURL(url=f"data:{mimeType};base64,{data}")
- )
- case mcp.types.EmbeddedResource(
- resource=mcp.types.BlobResourceContents(uri=_uri, mimeType=mimeType, blob=blob)
- ):
- mimeType = mimeType or "application/octet-stream"
- if mimeType.startswith("image/"):
- return kosong.message.ImageURLPart(
- type="image_url",
- image_url=kosong.message.ImageURLPart.ImageURL(
- url=f"data:{mimeType};base64,{blob}",
- ),
- )
- elif mimeType.startswith("audio/"):
- return kosong.message.AudioURLPart(
- type="audio_url",
- audio_url=kosong.message.AudioURLPart.AudioURL(
- url=f"data:{mimeType};base64,{blob}"
- ),
- )
-
- else:
- raise ValueError(f"Unsupported mime type: {mimeType}")
- case mcp.types.ResourceLink(uri=uri, mimeType=mimeType, description=_description):
- mimeType = mimeType or "application/octet-stream"
- if mimeType.startswith("image/"):
- return kosong.message.ImageURLPart(
- type="image_url",
- image_url=kosong.message.ImageURLPart.ImageURL(url=str(uri)),
- )
- elif mimeType.startswith("audio/"):
- return kosong.message.AudioURLPart(
- type="audio_url",
- audio_url=kosong.message.AudioURLPart.AudioURL(url=str(uri)),
- )
- else:
- raise ValueError(f"Unsupported mime type: {mimeType}")
- case _:
- raise ValueError(f"Unsupported MCP tool result part: {part}")
diff --git a/src/kosong/tooling/simple.py b/src/kosong/tooling/simple.py
deleted file mode 100644
index 76f94e5..0000000
--- a/src/kosong/tooling/simple.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import asyncio
-import inspect
-import json
-from collections.abc import Iterable
-from typing import TYPE_CHECKING, Any, Self
-
-from kosong.message import ToolCall
-from kosong.tooling import (
- CallableTool,
- CallableTool2,
- HandleResult,
- Tool,
- ToolResult,
- ToolReturnValue,
- Toolset,
-)
-from kosong.tooling.error import (
- ToolNotFoundError,
- ToolParseError,
- ToolRuntimeError,
-)
-from kosong.utils.typing import JsonType
-
-if TYPE_CHECKING:
-
- def type_check(
- simple: "SimpleToolset",
- ):
- _: Toolset = simple
-
-
-type ToolType = CallableTool | CallableTool2[Any]
-"""The tool type that can be added to the `SimpleToolset`."""
-
-
-class SimpleToolset:
- """A simple toolset that can handle tool calls concurrently."""
-
- _tool_dict: dict[str, ToolType]
-
- def __init__(self, tools: Iterable[ToolType] | None = None):
- """Initialize the simple toolset with an optional iterable of tools."""
- self._tool_dict = {}
- if tools:
- for tool in tools:
- self += tool
-
- def __iadd__(self, tool: ToolType) -> Self:
- """
- @public
- Add a tool to the toolset.
- """
- return_annotation = inspect.signature(tool.__call__).return_annotation
- if return_annotation is not ToolReturnValue:
- raise TypeError(
- f"Expected tool `{tool.name}` to return `ToolReturnValue`, "
- f"but got `{return_annotation}`"
- )
- self._tool_dict[tool.name] = tool
- return self
-
- def __add__(self, tool: ToolType) -> "SimpleToolset":
- """
- @public
- Return a new toolset with the given tool added.
- """
- new_toolset = SimpleToolset()
- new_toolset._tool_dict = self._tool_dict.copy()
- new_toolset += tool
- return new_toolset
-
- def add(self, tool: ToolType) -> None:
- """
- @public
- Add a tool to the toolset.
- """
- self += tool
-
- def remove(self, tool_name: str) -> None:
- """
- @public
- Remove a tool from the toolset.
- """
- if tool_name not in self._tool_dict:
- raise KeyError(f"Tool `{tool_name}` not found in the toolset.")
- del self._tool_dict[tool_name]
-
- @property
- def tools(self) -> list[Tool]:
- return [tool.base for tool in self._tool_dict.values()]
-
- def handle(self, tool_call: ToolCall) -> HandleResult:
- if tool_call.function.name not in self._tool_dict:
- return ToolResult(
- tool_call_id=tool_call.id,
- return_value=ToolNotFoundError(tool_call.function.name),
- )
-
- tool = self._tool_dict[tool_call.function.name]
-
- try:
- arguments: JsonType = json.loads(tool_call.function.arguments or "{}")
- except json.JSONDecodeError as e:
- return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e)))
-
- async def _call():
- try:
- ret = await tool.call(arguments)
- return ToolResult(tool_call_id=tool_call.id, return_value=ret)
- except Exception as e:
- return ToolResult(tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e)))
-
- return asyncio.create_task(_call())
diff --git a/src/kosong/utils/__init__.py b/src/kosong/utils/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/kosong/utils/aio.py b/src/kosong/utils/aio.py
deleted file mode 100644
index 319e324..0000000
--- a/src/kosong/utils/aio.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import inspect
-from collections.abc import Awaitable, Callable
-
-type Callback[**Params, Return] = Callable[Params, Awaitable[Return] | Return]
-
-
-async def callback[**Params, Return](
- fn: Callback[Params, Return], *args: Params.args, **kwargs: Params.kwargs
-) -> Return:
- ret = fn(*args, **kwargs)
- if inspect.isawaitable(ret):
- return await ret
- return ret
diff --git a/src/kosong/utils/jsonschema.py b/src/kosong/utils/jsonschema.py
deleted file mode 100644
index e339ca4..0000000
--- a/src/kosong/utils/jsonschema.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from __future__ import annotations
-
-import copy
-from typing import cast
-
-from kosong.utils.typing import JsonType
-
-type JsonDict = dict[str, JsonType]
-
-
-def deref_json_schema(schema: JsonDict) -> JsonDict:
- """Expand local `$ref` entries in a JSON Schema without infinite recursion."""
- # Work on a deep copy so we never mutate the caller's schema.
- full_schema: JsonDict = copy.deepcopy(schema)
-
- def resolve_pointer(root: JsonDict, pointer: str) -> JsonType:
- """Resolve a JSON Pointer (e.g. ``#/$defs/User``) inside the schema."""
- parts = pointer.lstrip("#/").split("/")
- current: JsonType = root
- try:
- for part in parts:
- if isinstance(current, dict):
- current = current[part]
- else:
- raise ValueError
- return current
- except (KeyError, TypeError, ValueError):
- raise ValueError(f"Unable to resolve reference path: {pointer}") from None
-
- def traverse(node: JsonType, root: JsonDict) -> JsonType:
- """Recursively traverse every node to inline local references."""
- if isinstance(node, dict):
- # Replace local ``$ref`` entries with their referenced payload.
- if "$ref" in node and isinstance(node["$ref"], str):
- ref_path = node["$ref"]
- if ref_path.startswith("#"):
- # Resolve the local reference target.
- target = resolve_pointer(root, ref_path)
- # Recursively inline the target in case it contains more refs.
- ref = traverse(target, root)
- if not isinstance(ref, dict):
- msg = "Local $ref must resolve to a JSON object"
- raise TypeError(msg)
- node.pop("$ref")
- node.update(ref)
- return node
- else:
- # Ignore remote references such as http://...
- return node
-
- # Traverse the remaining mapping entries.
- return {k: traverse(v, root) for k, v in node.items()}
-
- elif isinstance(node, list):
- # Traverse list members (e.g. allOf, oneOf, items).
- return [traverse(item, root) for item in node]
-
- else:
- return node
-
- # Remove definition buckets to keep the resolved schema minimal.
- resolved = cast(JsonDict, traverse(full_schema, full_schema))
-
- # Comment these lines if you want to keep the emitted definitions.
- resolved.pop("$defs", None)
- resolved.pop("definitions", None)
-
- return resolved
diff --git a/src/kosong/utils/typing.py b/src/kosong/utils/typing.py
deleted file mode 100644
index c7d89bf..0000000
--- a/src/kosong/utils/typing.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from __future__ import annotations
-
-type JsonType = None | int | float | str | bool | list[JsonType] | dict[str, JsonType]
diff --git a/tests/api_snapshot_tests/common.py b/tests/api_snapshot_tests/common.py
deleted file mode 100644
index 228c15b..0000000
--- a/tests/api_snapshot_tests/common.py
+++ /dev/null
@@ -1,252 +0,0 @@
-"""Common test cases and utilities for snapshot tests."""
-
-import json
-from collections.abc import Sequence
-from typing import Any, TypedDict
-
-import respx
-
-from kosong.chat_provider import ChatProvider
-from kosong.message import ImageURLPart, Message, TextPart, ToolCall
-from kosong.tooling import Tool
-
-__all__ = [
- "ADD_TOOL",
- "B64_PNG",
- "COMMON_CASES",
- "MUL_TOOL",
- "capture_request",
- "make_anthropic_response",
- "make_chat_completion_response",
- "run_test_cases",
-]
-
-
-def make_anthropic_response(model: str = "claude-sonnet-4-20250514") -> dict[str, Any]:
- """Common response for Anthropic Messages API."""
- return {
- "id": "msg_test_123",
- "type": "message",
- "role": "assistant",
- "model": model,
- "content": [{"type": "text", "text": "Hello"}],
- "stop_reason": "end_turn",
- "usage": {"input_tokens": 10, "output_tokens": 5},
- }
-
-
-def make_chat_completion_response(model: str = "test-model") -> dict[str, Any]:
- """Common response for OpenAI-compatible chat completion APIs."""
- return {
- "id": "chatcmpl-test123",
- "object": "chat.completion",
- "created": 1234567890,
- "model": model,
- "choices": [
- {
- "index": 0,
- "message": {"role": "assistant", "content": "Hello"},
- "finish_reason": "stop",
- }
- ],
- "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
- }
-
-
-B64_PNG = (
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA"
- "DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
-)
-
-ADD_TOOL = Tool(
- name="add",
- description="Add two integers.",
- parameters={
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
-)
-
-MUL_TOOL = Tool(
- name="multiply",
- description="Multiply two integers.",
- parameters={
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
-)
-
-
-class Case(TypedDict, total=False):
- """A test case for chat providers."""
-
- system: str
- """The system prompt."""
- tools: list[Tool]
- """The list of tools."""
- history: list[Message]
- """The message history."""
-
-
-# Common test cases shared across providers
-COMMON_CASES: dict[str, Case] = {
- "simple_user_message": {
- "system": "You are helpful.",
- "history": [Message(role="user", content="Hello!")],
- },
- "multi_turn_conversation": {
- "history": [
- Message(role="user", content="What is 2+2?"),
- Message(role="assistant", content="2+2 equals 4."),
- Message(role="user", content="And 3+3?"),
- ],
- },
- "multi_turn_with_system": {
- "system": "You are a math tutor.",
- "history": [
- Message(role="user", content="What is 2+2?"),
- Message(role="assistant", content="2+2 equals 4."),
- Message(role="user", content="And 3+3?"),
- ],
- },
- "image_url": {
- "history": [
- Message(
- role="user",
- content=[
- TextPart(text="What's in this image?"),
- ImageURLPart(
- image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")
- ),
- ],
- )
- ],
- },
- "tool_definition": {
- "history": [Message(role="user", content="Add 2 and 3")],
- "tools": [ADD_TOOL, MUL_TOOL],
- },
- "tool_call": {
- "history": [
- Message(role="user", content="Add 2 and 3"),
- Message(
- role="assistant",
- content="I'll add those numbers for you.",
- tool_calls=[
- ToolCall(
- id="call_abc123",
- function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'),
- )
- ],
- ),
- Message(role="tool", content="5", tool_call_id="call_abc123"),
- ],
- },
- "tool_call_with_image": {
- "history": [
- Message(role="user", content="Add 2 and 3"),
- Message(
- role="assistant",
- content="I'll add those numbers for you.",
- tool_calls=[
- ToolCall(
- id="call_abc123",
- function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'),
- )
- ],
- ),
- Message(
- role="tool",
- content=[
- TextPart(text="5"),
- ImageURLPart(
- image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")
- ),
- ],
- tool_call_id="call_abc123",
- ),
- ],
- },
- "parallel_tool_calls": {
- "tools": [ADD_TOOL, MUL_TOOL],
- "history": [
- Message(role="user", content="Calculate 2+3 and 4*5"),
- Message(
- role="assistant",
- content="I'll calculate both.",
- tool_calls=[
- ToolCall(
- id="call_add",
- function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'),
- ),
- ToolCall(
- id="call_mul",
- function=ToolCall.FunctionBody(
- name="multiply", arguments='{"a": 4, "b": 5}'
- ),
- ),
- ],
- ),
- Message(
- role="tool",
- content=[
- TextPart(text="This is a system reminder"),
- TextPart(text="5"),
- ],
- tool_call_id="call_add",
- ),
- Message(
- role="tool",
- content=[
- TextPart(text="This is a system reminder"),
- TextPart(text="20"),
- ],
- tool_call_id="call_mul",
- ),
- ],
- },
-}
-
-
-async def capture_request(
- mock: respx.MockRouter,
- provider: ChatProvider,
- system: str,
- tools: Sequence[Tool],
- history: list[Message],
-) -> dict[str, Any]:
- """Generate and capture the request body."""
- stream = await provider.generate(system, tools, history)
- async for _ in stream:
- pass
- request = mock.calls.last.request
- assert request.content is not None
- return json.loads(request.content.decode())
-
-
-async def run_test_cases(
- mock: respx.MockRouter,
- provider: ChatProvider,
- cases: dict[str, Case],
- extract_keys: tuple[str, ...],
-) -> dict[str, dict[str, Any]]:
- """Run all test cases and return results dict for snapshot comparison."""
- results: dict[str, dict[str, Any]] = {}
- for name, case in cases.items():
- body = await capture_request(
- mock,
- provider,
- case.get("system", ""),
- case.get("tools", []),
- case.get("history", []),
- )
- results[name] = {k: v for k, v in body.items() if k in extract_keys}
- return results
diff --git a/tests/api_snapshot_tests/test_anthropic.py b/tests/api_snapshot_tests/test_anthropic.py
deleted file mode 100644
index 5ba9f94..0000000
--- a/tests/api_snapshot_tests/test_anthropic.py
+++ /dev/null
@@ -1,508 +0,0 @@
-"""Snapshot tests for Anthropic chat provider."""
-
-import json
-
-import pytest
-import respx
-from common import B64_PNG, COMMON_CASES, Case, make_anthropic_response, run_test_cases
-from httpx import Response
-from inline_snapshot import snapshot
-
-pytest.importorskip("anthropic", reason="Optional contrib dependency not installed")
-
-from kosong.contrib.chat_provider.anthropic import Anthropic
-from kosong.message import ImageURLPart, Message, TextPart, ThinkPart
-
-TEST_CASES: dict[str, Case] = {
- **COMMON_CASES,
- "assistant_with_thinking": {
- "history": [
- Message(role="user", content="What is 2+2?"),
- Message(
- role="assistant",
- content=[
- ThinkPart(think="Let me think...", encrypted="sig_abc123"),
- TextPart(text="The answer is 4."),
- ],
- ),
- Message(role="user", content="Thanks!"),
- ],
- },
- "thinking_without_signature_stripped": {
- "history": [
- Message(role="user", content="Hi"),
- Message(
- role="assistant",
- content=[ThinkPart(think="Thinking..."), TextPart(text="Hello!")],
- ),
- Message(role="user", content="Bye"),
- ],
- },
- "base64_image": {
- "history": [
- Message(
- role="user",
- content=[
- TextPart(text="Describe:"),
- ImageURLPart(
- image_url=ImageURLPart.ImageURL(url=f"data:image/png;base64,{B64_PNG}")
- ),
- ],
- )
- ],
- },
- "redacted_thinking": {
- "history": [
- Message(role="user", content="What is 2+2?"),
- Message(
- role="assistant",
- content=[
- ThinkPart(think="", encrypted="enc_redacted_sig_xyz"),
- TextPart(text="4."),
- ],
- ),
- Message(role="user", content="Thanks!"),
- ],
- },
-}
-
-
-@pytest.mark.asyncio
-async def test_anthropic_message_conversion():
- with respx.mock(base_url="https://api.anthropic.com") as mock:
- mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response()))
- provider = Anthropic(
- model="claude-sonnet-4-20250514",
- api_key="test-key",
- default_max_tokens=1024,
- stream=False,
- )
- results = await run_test_cases(mock, provider, TEST_CASES, ("messages", "system", "tools"))
-
- assert results == snapshot(
- {
- "simple_user_message": {
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Hello!",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- }
- ],
- "system": [
- {
- "type": "text",
- "text": "You are helpful.",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- "tools": [],
- },
- "multi_turn_conversation": {
- "messages": [
- {"role": "user", "content": [{"type": "text", "text": "What is 2+2?"}]},
- {
- "role": "assistant",
- "content": [{"type": "text", "text": "2+2 equals 4."}],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "And 3+3?",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [],
- },
- "multi_turn_with_system": {
- "messages": [
- {"role": "user", "content": [{"type": "text", "text": "What is 2+2?"}]},
- {
- "role": "assistant",
- "content": [{"type": "text", "text": "2+2 equals 4."}],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "And 3+3?",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "system": [
- {
- "text": "You are a math tutor.",
- "type": "text",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- "tools": [],
- },
- "image_url": {
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "What's in this image?"},
- {
- "type": "image",
- "source": {
- "type": "url",
- "url": "https://example.com/image.png",
- },
- "cache_control": {"type": "ephemeral"},
- },
- ],
- }
- ],
- "tools": [],
- },
- "tool_definition": {
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Add 2 and 3",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- }
- ],
- "tools": [
- {
- "name": "add",
- "description": "Add two integers.",
- "input_schema": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- {
- "name": "multiply",
- "description": "Multiply two integers.",
- "input_schema": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- "cache_control": {"type": "ephemeral"},
- },
- ],
- },
- "tool_call_with_image": {
- "messages": [
- {"role": "user", "content": [{"type": "text", "text": "Add 2 and 3"}]},
- {
- "role": "assistant",
- "content": [
- {"type": "text", "text": "I'll add those numbers for you."},
- {
- "type": "tool_use",
- "id": "call_abc123",
- "name": "add",
- "input": {"a": 2, "b": 3},
- },
- ],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "tool_result",
- "tool_use_id": "call_abc123",
- "content": [
- {"type": "text", "text": "5"},
- {
- "type": "image",
- "source": {
- "type": "url",
- "url": "https://example.com/image.png",
- },
- },
- ],
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [],
- },
- "tool_call": {
- "messages": [
- {"role": "user", "content": [{"type": "text", "text": "Add 2 and 3"}]},
- {
- "role": "assistant",
- "content": [
- {"type": "text", "text": "I'll add those numbers for you."},
- {
- "type": "tool_use",
- "id": "call_abc123",
- "name": "add",
- "input": {"a": 2, "b": 3},
- },
- ],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "tool_result",
- "tool_use_id": "call_abc123",
- "content": [{"type": "text", "text": "5"}],
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [],
- },
- "parallel_tool_calls": {
- "messages": [
- {
- "role": "user",
- "content": [{"type": "text", "text": "Calculate 2+3 and 4*5"}],
- },
- {
- "role": "assistant",
- "content": [
- {"type": "text", "text": "I'll calculate both."},
- {
- "type": "tool_use",
- "id": "call_add",
- "name": "add",
- "input": {"a": 2, "b": 3},
- },
- {
- "type": "tool_use",
- "id": "call_mul",
- "name": "multiply",
- "input": {"a": 4, "b": 5},
- },
- ],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "tool_result",
- "tool_use_id": "call_add",
- "content": [
- {
- "type": "text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "text", "text": "5"},
- ],
- }
- ],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "tool_result",
- "tool_use_id": "call_mul",
- "content": [
- {
- "type": "text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "text", "text": "20"},
- ],
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [
- {
- "name": "add",
- "description": "Add two integers.",
- "input_schema": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- {
- "name": "multiply",
- "description": "Multiply two integers.",
- "input_schema": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- "cache_control": {"type": "ephemeral"},
- },
- ],
- },
- "assistant_with_thinking": {
- "messages": [
- {
- "role": "user",
- "content": [{"type": "text", "text": "What is 2+2?"}],
- },
- {
- "role": "assistant",
- "content": [
- {
- "type": "thinking",
- "thinking": "Let me think...",
- "signature": "sig_abc123",
- },
- {"type": "text", "text": "The answer is 4."},
- ],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Thanks!",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [],
- },
- "thinking_without_signature_stripped": {
- "messages": [
- {
- "role": "user",
- "content": [{"type": "text", "text": "Hi"}],
- },
- {
- "role": "assistant",
- "content": [{"type": "text", "text": "Hello!"}],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Bye",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [],
- },
- "base64_image": {
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "Describe:"},
- {
- "type": "image",
- "source": {
- "type": "base64",
- "data": B64_PNG,
- "media_type": "image/png",
- },
- "cache_control": {"type": "ephemeral"},
- },
- ],
- }
- ],
- "tools": [],
- },
- "redacted_thinking": {
- "messages": [
- {
- "role": "user",
- "content": [{"type": "text", "text": "What is 2+2?"}],
- },
- {
- "role": "assistant",
- "content": [
- {
- "type": "thinking",
- "thinking": "",
- "signature": "enc_redacted_sig_xyz",
- },
- {"type": "text", "text": "4."},
- ],
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Thanks!",
- "cache_control": {"type": "ephemeral"},
- }
- ],
- },
- ],
- "tools": [],
- },
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_anthropic_generation_kwargs():
- with respx.mock(base_url="https://api.anthropic.com") as mock:
- mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response()))
- provider = Anthropic(
- model="claude-sonnet-4-20250514",
- api_key="test-key",
- default_max_tokens=1024,
- stream=False,
- ).with_generation_kwargs(temperature=0.7, top_p=0.9, max_tokens=2048)
- stream = await provider.generate("", [], [Message(role="user", content="Hi")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert (body["temperature"], body["top_p"], body["max_tokens"]) == snapshot(
- (0.7, 0.9, 2048)
- )
-
-
-@pytest.mark.asyncio
-async def test_anthropic_with_thinking():
- with respx.mock(base_url="https://api.anthropic.com") as mock:
- mock.post("/v1/messages").mock(return_value=Response(200, json=make_anthropic_response()))
- provider = Anthropic(
- model="claude-sonnet-4-20250514",
- api_key="test-key",
- default_max_tokens=1024,
- stream=False,
- ).with_thinking("high")
- stream = await provider.generate("", [], [Message(role="user", content="Think")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert body["thinking"] == snapshot({"type": "enabled", "budget_tokens": 32000})
diff --git a/tests/api_snapshot_tests/test_google_genai.py b/tests/api_snapshot_tests/test_google_genai.py
deleted file mode 100644
index 385101e..0000000
--- a/tests/api_snapshot_tests/test_google_genai.py
+++ /dev/null
@@ -1,540 +0,0 @@
-"""Snapshot tests for Google GenAI (Gemini) chat provider."""
-
-import json
-from typing import Any
-
-import pytest
-import respx
-from common import COMMON_CASES, Case, run_test_cases
-from httpx import Response
-from inline_snapshot import snapshot
-
-pytest.importorskip("google.genai", reason="Optional contrib dependency not installed")
-
-from kosong.contrib.chat_provider.google_genai import GoogleGenAI
-from kosong.message import Message, TextPart, ToolCall
-
-
-def make_response() -> dict[str, Any]:
- return {
- "candidates": [
- {
- "content": {"parts": [{"text": "Hello"}], "role": "model"},
- "finishReason": "STOP",
- }
- ],
- "usageMetadata": {
- "promptTokenCount": 10,
- "candidatesTokenCount": 5,
- "totalTokenCount": 15,
- },
- "modelVersion": "gemini-2.5-flash",
- }
-
-
-TEST_CASES: dict[str, Case] = {
- # Google GenAI doesn't support image_url in the same way, use subset of common cases
- **{k: v for k, v in COMMON_CASES.items() if "image" not in k},
- "tool_call_with_thought_signature": {
- "history": [
- Message(role="user", content="Add 2 and 3"),
- Message(
- role="assistant",
- content=[TextPart(text="I'll add those.")],
- tool_calls=[
- ToolCall(
- id="add_call_sig",
- function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'),
- extras={"thought_signature_b64": "dGhvdWdodF9zaWduYXR1cmVfZGF0YQ=="},
- )
- ],
- ),
- ],
- },
-}
-
-
-@pytest.mark.asyncio
-async def test_google_genai_message_conversion():
- with respx.mock(base_url="https://generativelanguage.googleapis.com") as mock:
- mock.route(method="POST", path__regex=r"/v1beta/models/.+:generateContent").mock(
- return_value=Response(200, json=make_response())
- )
- provider = GoogleGenAI(model="gemini-2.5-flash", api_key="test-key", stream=False)
- results = await run_test_cases(
- mock, provider, TEST_CASES, ("contents", "systemInstruction", "tools")
- )
-
- assert results == snapshot(
- {
- "simple_user_message": {
- "contents": [{"parts": [{"text": "Hello!"}], "role": "user"}],
- "systemInstruction": {
- "parts": [{"text": "You are helpful."}],
- "role": "user",
- },
- },
- "multi_turn_conversation": {
- "contents": [
- {"parts": [{"text": "What is 2+2?"}], "role": "user"},
- {"parts": [{"text": "2+2 equals 4."}], "role": "model"},
- {"parts": [{"text": "And 3+3?"}], "role": "user"},
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- },
- "multi_turn_with_system": {
- "contents": [
- {"parts": [{"text": "What is 2+2?"}], "role": "user"},
- {"parts": [{"text": "2+2 equals 4."}], "role": "model"},
- {"parts": [{"text": "And 3+3?"}], "role": "user"},
- ],
- "systemInstruction": {
- "parts": [{"text": "You are a math tutor."}],
- "role": "user",
- },
- },
- "tool_definition": {
- "contents": [{"parts": [{"text": "Add 2 and 3"}], "role": "user"}],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- "tools": [
- {
- "functionDeclarations": [
- {
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "OBJECT",
- "properties": {
- "a": {
- "type": "INTEGER",
- "description": "First number",
- },
- "b": {
- "type": "INTEGER",
- "description": "Second number",
- },
- },
- "required": ["a", "b"],
- },
- },
- {
- "description": "Multiply two integers.",
- "name": "multiply",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- ]
- }
- ],
- },
- "tool_call": {
- "contents": [
- {"parts": [{"text": "Add 2 and 3"}], "role": "user"},
- {
- "parts": [
- {"text": "I'll add those numbers for you."},
- {
- "functionCall": {
- "id": "call_abc123",
- "args": {"a": 2, "b": 3},
- "name": "add",
- }
- },
- ],
- "role": "model",
- },
- {
- "parts": [
- {
- "functionResponse": {
- "parts": [],
- "id": "call_abc123",
- "name": "add",
- "response": {"output": "5"},
- }
- }
- ],
- "role": "user",
- },
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- },
- "parallel_tool_calls": {
- "contents": [
- {"parts": [{"text": "Calculate 2+3 and 4*5"}], "role": "user"},
- {
- "parts": [
- {"text": "I'll calculate both."},
- {
- "functionCall": {
- "id": "call_add",
- "name": "add",
- "args": {"a": 2, "b": 3},
- }
- },
- {
- "functionCall": {
- "id": "call_mul",
- "name": "multiply",
- "args": {"a": 4, "b": 5},
- }
- },
- ],
- "role": "model",
- },
- {
- "parts": [
- {
- "functionResponse": {
- "parts": [],
- "id": "call_add",
- "name": "add",
- "response": {
- "output": "This is a system reminder"
- "5"
- },
- }
- },
- {
- "functionResponse": {
- "parts": [],
- "id": "call_mul",
- "name": "multiply",
- "response": {
- "output": "This is a system reminder"
- "20"
- },
- }
- },
- ],
- "role": "user",
- },
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- "tools": [
- {
- "functionDeclarations": [
- {
- "description": "Add two integers.",
- "name": "add",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- {
- "description": "Multiply two integers.",
- "name": "multiply",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- ]
- }
- ],
- },
- "tool_call_with_thought_signature": {
- "contents": [
- {"parts": [{"text": "Add 2 and 3"}], "role": "user"},
- {
- "parts": [
- {"text": "I'll add those."},
- {
- "functionCall": {
- "id": "add_call_sig",
- "name": "add",
- "args": {"a": 2, "b": 3},
- },
- "thoughtSignature": "dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==",
- },
- ],
- "role": "model",
- },
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- },
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_google_genai_vertexai_message_conversion():
- with respx.mock(base_url="https://aiplatform.googleapis.com") as mock:
- mock.route(
- method="POST",
- path__regex=r"/v1beta1/publishers/google/models/gemini-3-pro-preview:generateContent",
- ).mock(return_value=Response(200, json=make_response()))
- provider = GoogleGenAI(
- model="gemini-3-pro-preview",
- api_key="test-key",
- stream=False,
- vertexai=True,
- )
- results = await run_test_cases(
- mock, provider, TEST_CASES, ("contents", "systemInstruction", "tools")
- )
- assert results == snapshot(
- {
- "simple_user_message": {
- "contents": [{"parts": [{"text": "Hello!"}], "role": "user"}],
- "systemInstruction": {"parts": [{"text": "You are helpful."}], "role": "user"},
- },
- "multi_turn_conversation": {
- "contents": [
- {"parts": [{"text": "What is 2+2?"}], "role": "user"},
- {"parts": [{"text": "2+2 equals 4."}], "role": "model"},
- {"parts": [{"text": "And 3+3?"}], "role": "user"},
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- },
- "multi_turn_with_system": {
- "contents": [
- {"parts": [{"text": "What is 2+2?"}], "role": "user"},
- {"parts": [{"text": "2+2 equals 4."}], "role": "model"},
- {"parts": [{"text": "And 3+3?"}], "role": "user"},
- ],
- "systemInstruction": {
- "parts": [{"text": "You are a math tutor."}],
- "role": "user",
- },
- },
- "tool_definition": {
- "contents": [{"parts": [{"text": "Add 2 and 3"}], "role": "user"}],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- "tools": [
- {
- "functionDeclarations": [
- {
- "description": "Add two integers.",
- "name": "add",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- {
- "description": "Multiply two integers.",
- "name": "multiply",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- ]
- }
- ],
- },
- "tool_call": {
- "contents": [
- {"parts": [{"text": "Add 2 and 3"}], "role": "user"},
- {
- "parts": [
- {"text": "I'll add those numbers for you."},
- {
- "function_call": {
- "id": "call_abc123",
- "args": {"a": 2, "b": 3},
- "name": "add",
- }
- },
- ],
- "role": "model",
- },
- {
- "parts": [
- {
- "function_response": {
- "parts": [],
- "id": "call_abc123",
- "name": "add",
- "response": {"output": "5"},
- }
- }
- ],
- "role": "user",
- },
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- },
- "parallel_tool_calls": {
- "contents": [
- {"parts": [{"text": "Calculate 2+3 and 4*5"}], "role": "user"},
- {
- "parts": [
- {"text": "I'll calculate both."},
- {
- "function_call": {
- "id": "call_add",
- "args": {"a": 2, "b": 3},
- "name": "add",
- }
- },
- {
- "function_call": {
- "id": "call_mul",
- "args": {"a": 4, "b": 5},
- "name": "multiply",
- }
- },
- ],
- "role": "model",
- },
- {
- "parts": [
- {
- "function_response": {
- "parts": [],
- "id": "call_add",
- "name": "add",
- "response": {
- "output": "This is a system reminder5" # noqa: E501
- },
- }
- },
- {
- "function_response": {
- "parts": [],
- "id": "call_mul",
- "name": "multiply",
- "response": {
- "output": "This is a system reminder20" # noqa: E501
- },
- }
- },
- ],
- "role": "user",
- },
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- "tools": [
- {
- "functionDeclarations": [
- {
- "description": "Add two integers.",
- "name": "add",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- {
- "description": "Multiply two integers.",
- "name": "multiply",
- "parameters": {
- "properties": {
- "a": {"description": "First number", "type": "INTEGER"},
- "b": {
- "description": "Second number",
- "type": "INTEGER",
- },
- },
- "required": ["a", "b"],
- "type": "OBJECT",
- },
- },
- ]
- }
- ],
- },
- "tool_call_with_thought_signature": {
- "contents": [
- {"parts": [{"text": "Add 2 and 3"}], "role": "user"},
- {
- "parts": [
- {"text": "I'll add those."},
- {
- "function_call": {
- "id": "add_call_sig",
- "args": {"a": 2, "b": 3},
- "name": "add",
- },
- "thought_signature": "dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==",
- },
- ],
- "role": "model",
- },
- ],
- "systemInstruction": {"parts": [{"text": ""}], "role": "user"},
- },
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_google_genai_generation_kwargs():
- with respx.mock(base_url="https://generativelanguage.googleapis.com") as mock:
- mock.route(method="POST", path__regex=r"/v1beta/models/.+:generateContent").mock(
- return_value=Response(200, json=make_response())
- )
- provider = GoogleGenAI(
- model="gemini-2.5-flash", api_key="test-key", stream=False
- ).with_generation_kwargs(temperature=0.7, max_output_tokens=2048)
- stream = await provider.generate("", [], [Message(role="user", content="Hi")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- config = body.get("generationConfig", {})
- assert (config.get("temperature"), config.get("maxOutputTokens")) == snapshot((0.7, 2048))
-
-
-@pytest.mark.asyncio
-async def test_google_genai_with_thinking():
- with respx.mock(base_url="https://generativelanguage.googleapis.com") as mock:
- mock.route(method="POST", path__regex=r"/v1beta/models/.+:generateContent").mock(
- return_value=Response(200, json=make_response())
- )
- provider = GoogleGenAI(
- model="gemini-2.5-flash", api_key="test-key", stream=False
- ).with_thinking("high")
- stream = await provider.generate("", [], [Message(role="user", content="Think")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert body.get("generationConfig", {}).get("thinkingConfig") == snapshot(
- {"include_thoughts": True, "thinking_budget": 32000}
- )
diff --git a/tests/api_snapshot_tests/test_kimi.py b/tests/api_snapshot_tests/test_kimi.py
deleted file mode 100644
index a3cf51c..0000000
--- a/tests/api_snapshot_tests/test_kimi.py
+++ /dev/null
@@ -1,323 +0,0 @@
-"""Snapshot tests for Kimi chat provider."""
-
-import json
-
-import pytest
-import respx
-from common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases
-from httpx import Response
-from inline_snapshot import snapshot
-
-from kosong.chat_provider.kimi import Kimi
-from kosong.message import Message, TextPart, ThinkPart
-from kosong.tooling import Tool
-
-BUILTIN_TOOL = Tool(
- name="$web_search",
- description="Search the web",
- parameters={"type": "object", "properties": {}},
-)
-
-TEST_CASES: dict[str, Case] = {
- **COMMON_CASES,
- "builtin_tool": {
- "history": [Message(role="user", content="Search for something")],
- "tools": [BUILTIN_TOOL],
- },
- "assistant_with_reasoning": {
- "history": [
- Message(role="user", content="What is 2+2?"),
- Message(
- role="assistant",
- content=[
- ThinkPart(think="Let me think..."),
- TextPart(text="The answer is 4."),
- ],
- ),
- Message(role="user", content="Thanks!"),
- ],
- },
-}
-
-
-@pytest.mark.asyncio
-async def test_kimi_message_conversion():
- with respx.mock(base_url="https://api.moonshot.ai") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response("kimi-k2"))
- )
- provider = Kimi(model="kimi-k2-turbo-preview", api_key="test-key", stream=False)
- results = await run_test_cases(mock, provider, TEST_CASES, ("messages", "tools"))
-
- assert results == snapshot(
- {
- "simple_user_message": {
- "messages": [
- {"role": "system", "content": "You are helpful."},
- {"role": "user", "content": "Hello!"},
- ],
- "tools": [],
- },
- "multi_turn_conversation": {
- "messages": [
- {"role": "user", "content": "What is 2+2?"},
- {"role": "assistant", "content": "2+2 equals 4."},
- {"role": "user", "content": "And 3+3?"},
- ],
- "tools": [],
- },
- "multi_turn_with_system": {
- "messages": [
- {"role": "system", "content": "You are a math tutor."},
- {"role": "user", "content": "What is 2+2?"},
- {"role": "assistant", "content": "2+2 equals 4."},
- {"role": "user", "content": "And 3+3?"},
- ],
- "tools": [],
- },
- "image_url": {
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "What's in this image?"},
- {
- "type": "image_url",
- "image_url": {
- "url": "https://example.com/image.png",
- "id": None,
- },
- },
- ],
- }
- ],
- "tools": [],
- },
- "tool_definition": {
- "messages": [{"role": "user", "content": "Add 2 and 3"}],
- "tools": [
- {
- "type": "function",
- "function": {
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {
- "type": "integer",
- "description": "First number",
- },
- "b": {
- "type": "integer",
- "description": "Second number",
- },
- },
- "required": ["a", "b"],
- },
- },
- },
- {
- "type": "function",
- "function": {
- "name": "multiply",
- "description": "Multiply two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- },
- ],
- },
- "tool_call_with_image": {
- "messages": [
- {"role": "user", "content": "Add 2 and 3"},
- {
- "role": "assistant",
- "content": "I'll add those numbers for you.",
- "tool_calls": [
- {
- "type": "function",
- "id": "call_abc123",
- "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'},
- }
- ],
- },
- {
- "role": "tool",
- "content": [
- {"type": "text", "text": "5"},
- {
- "type": "image_url",
- "image_url": {
- "url": "https://example.com/image.png",
- "id": None,
- },
- },
- ],
- "tool_call_id": "call_abc123",
- },
- ],
- "tools": [],
- },
- "tool_call": {
- "messages": [
- {"role": "user", "content": "Add 2 and 3"},
- {
- "role": "assistant",
- "content": "I'll add those numbers for you.",
- "tool_calls": [
- {
- "type": "function",
- "id": "call_abc123",
- "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'},
- }
- ],
- },
- {"role": "tool", "content": "5", "tool_call_id": "call_abc123"},
- ],
- "tools": [],
- },
- "parallel_tool_calls": {
- "messages": [
- {"role": "user", "content": "Calculate 2+3 and 4*5"},
- {
- "role": "assistant",
- "content": "I'll calculate both.",
- "tool_calls": [
- {
- "type": "function",
- "id": "call_add",
- "function": {
- "name": "add",
- "arguments": '{"a": 2, "b": 3}',
- },
- },
- {
- "type": "function",
- "id": "call_mul",
- "function": {
- "name": "multiply",
- "arguments": '{"a": 4, "b": 5}',
- },
- },
- ],
- },
- {
- "role": "tool",
- "content": [
- {
- "type": "text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "text", "text": "5"},
- ],
- "tool_call_id": "call_add",
- },
- {
- "role": "tool",
- "content": [
- {
- "type": "text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "text", "text": "20"},
- ],
- "tool_call_id": "call_mul",
- },
- ],
- "tools": [
- {
- "type": "function",
- "function": {
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- },
- {
- "type": "function",
- "function": {
- "name": "multiply",
- "description": "Multiply two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- },
- ],
- },
- "builtin_tool": {
- "messages": [{"role": "user", "content": "Search for something"}],
- "tools": [
- {
- "type": "builtin_function",
- "function": {"name": "$web_search"},
- }
- ],
- },
- "assistant_with_reasoning": {
- "messages": [
- {"role": "user", "content": "What is 2+2?"},
- {
- "role": "assistant",
- "content": "The answer is 4.",
- "reasoning_content": "Let me think...",
- },
- {"role": "user", "content": "Thanks!"},
- ],
- "tools": [],
- },
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_kimi_generation_kwargs():
- with respx.mock(base_url="https://api.moonshot.ai") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response())
- )
- provider = Kimi(
- model="kimi-k2-turbo-preview", api_key="test-key", stream=False
- ).with_generation_kwargs(temperature=0.7, max_tokens=2048)
- stream = await provider.generate("", [], [Message(role="user", content="Hi")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert (body["temperature"], body["max_tokens"]) == snapshot((0.7, 2048))
-
-
-@pytest.mark.asyncio
-async def test_kimi_with_thinking():
- with respx.mock(base_url="https://api.moonshot.ai") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response())
- )
- provider = Kimi(
- model="kimi-k2-turbo-preview", api_key="test-key", stream=False
- ).with_thinking("high")
- stream = await provider.generate("", [], [Message(role="user", content="Think")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert (body["reasoning_effort"], body["temperature"]) == snapshot(("high", 1.0))
diff --git a/tests/api_snapshot_tests/test_openai_legacy.py b/tests/api_snapshot_tests/test_openai_legacy.py
deleted file mode 100644
index 755062f..0000000
--- a/tests/api_snapshot_tests/test_openai_legacy.py
+++ /dev/null
@@ -1,313 +0,0 @@
-"""Snapshot tests for OpenAI Legacy (Chat Completions API) chat provider."""
-
-import json
-
-import pytest
-import respx
-from common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases
-from httpx import Response
-from inline_snapshot import snapshot
-
-from kosong.contrib.chat_provider.openai_legacy import OpenAILegacy
-from kosong.message import Message, TextPart, ThinkPart
-
-TEST_CASES: dict[str, Case] = {**COMMON_CASES}
-
-
-@pytest.mark.asyncio
-async def test_openai_legacy_message_conversion():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response("gpt-4.1"))
- )
- provider = OpenAILegacy(model="gpt-4.1", api_key="test-key", stream=False)
- results = await run_test_cases(mock, provider, TEST_CASES, ("messages", "tools"))
-
- assert results == snapshot(
- {
- "simple_user_message": {
- "messages": [
- {"role": "system", "content": "You are helpful."},
- {"role": "user", "content": "Hello!"},
- ],
- "tools": [],
- },
- "multi_turn_conversation": {
- "messages": [
- {"role": "user", "content": "What is 2+2?"},
- {"role": "assistant", "content": "2+2 equals 4."},
- {"role": "user", "content": "And 3+3?"},
- ],
- "tools": [],
- },
- "multi_turn_with_system": {
- "messages": [
- {"role": "system", "content": "You are a math tutor."},
- {"role": "user", "content": "What is 2+2?"},
- {"role": "assistant", "content": "2+2 equals 4."},
- {"role": "user", "content": "And 3+3?"},
- ],
- "tools": [],
- },
- "image_url": {
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "What's in this image?"},
- {
- "type": "image_url",
- "image_url": {
- "url": "https://example.com/image.png",
- "id": None,
- },
- },
- ],
- }
- ],
- "tools": [],
- },
- "tool_definition": {
- "messages": [{"role": "user", "content": "Add 2 and 3"}],
- "tools": [
- {
- "type": "function",
- "function": {
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {
- "type": "integer",
- "description": "First number",
- },
- "b": {
- "type": "integer",
- "description": "Second number",
- },
- },
- "required": ["a", "b"],
- },
- },
- },
- {
- "type": "function",
- "function": {
- "name": "multiply",
- "description": "Multiply two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- },
- ],
- },
- "tool_call_with_image": {
- "messages": [
- {"role": "user", "content": "Add 2 and 3"},
- {
- "role": "assistant",
- "content": "I'll add those numbers for you.",
- "tool_calls": [
- {
- "type": "function",
- "id": "call_abc123",
- "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'},
- }
- ],
- },
- {
- "role": "tool",
- "content": [
- {"type": "text", "text": "5"},
- {
- "type": "image_url",
- "image_url": {
- "url": "https://example.com/image.png",
- "id": None,
- },
- },
- ],
- "tool_call_id": "call_abc123",
- },
- ],
- "tools": [],
- },
- "tool_call": {
- "messages": [
- {"role": "user", "content": "Add 2 and 3"},
- {
- "role": "assistant",
- "content": "I'll add those numbers for you.",
- "tool_calls": [
- {
- "type": "function",
- "id": "call_abc123",
- "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'},
- }
- ],
- },
- {"role": "tool", "content": "5", "tool_call_id": "call_abc123"},
- ],
- "tools": [],
- },
- "parallel_tool_calls": {
- "messages": [
- {"role": "user", "content": "Calculate 2+3 and 4*5"},
- {
- "role": "assistant",
- "content": "I'll calculate both.",
- "tool_calls": [
- {
- "type": "function",
- "id": "call_add",
- "function": {
- "name": "add",
- "arguments": '{"a": 2, "b": 3}',
- },
- },
- {
- "type": "function",
- "id": "call_mul",
- "function": {
- "name": "multiply",
- "arguments": '{"a": 4, "b": 5}',
- },
- },
- ],
- },
- {
- "role": "tool",
- "content": [
- {
- "type": "text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "text", "text": "5"},
- ],
- "tool_call_id": "call_add",
- },
- {
- "role": "tool",
- "content": [
- {
- "type": "text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "text", "text": "20"},
- ],
- "tool_call_id": "call_mul",
- },
- ],
- "tools": [
- {
- "type": "function",
- "function": {
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- },
- {
- "type": "function",
- "function": {
- "name": "multiply",
- "description": "Multiply two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- },
- },
- ],
- },
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_openai_legacy_reasoning_content():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response())
- )
- provider = OpenAILegacy(
- model="deepseek-reasoner",
- api_key="test-key",
- stream=False,
- reasoning_key="reasoning_content",
- )
- history = [
- Message(role="user", content="What is 2+2?"),
- Message(
- role="assistant",
- content=[ThinkPart(think="Thinking..."), TextPart(text="4.")],
- ),
- Message(role="user", content="Thanks!"),
- ]
- stream = await provider.generate("", [], history)
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert body["messages"] == snapshot(
- [
- {"role": "user", "content": "What is 2+2?"},
- {
- "role": "assistant",
- "content": "4.",
- "reasoning_content": "Thinking...",
- },
- {"role": "user", "content": "Thanks!"},
- ]
- )
-
-
-@pytest.mark.asyncio
-async def test_openai_legacy_generation_kwargs():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response())
- )
- provider = OpenAILegacy(
- model="gpt-4.1", api_key="test-key", stream=False
- ).with_generation_kwargs(temperature=0.7, max_tokens=2048)
- stream = await provider.generate("", [], [Message(role="user", content="Hi")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert (body["temperature"], body["max_tokens"]) == snapshot((0.7, 2048))
-
-
-@pytest.mark.asyncio
-async def test_openai_legacy_with_thinking():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/chat/completions").mock(
- return_value=Response(200, json=make_chat_completion_response())
- )
- provider = OpenAILegacy(model="gpt-4.1", api_key="test-key", stream=False).with_thinking(
- "high"
- )
- stream = await provider.generate("", [], [Message(role="user", content="Think")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert body["reasoning_effort"] == snapshot("high")
diff --git a/tests/api_snapshot_tests/test_openai_responses.py b/tests/api_snapshot_tests/test_openai_responses.py
deleted file mode 100644
index b89acee..0000000
--- a/tests/api_snapshot_tests/test_openai_responses.py
+++ /dev/null
@@ -1,396 +0,0 @@
-"""Snapshot tests for OpenAI Responses API chat provider."""
-
-import json
-from typing import Any
-
-import pytest
-import respx
-from common import COMMON_CASES, Case, run_test_cases
-from httpx import Response
-from inline_snapshot import snapshot
-
-from kosong.contrib.chat_provider.openai_responses import OpenAIResponses
-from kosong.message import Message, TextPart, ThinkPart
-
-
-def make_response() -> dict[str, Any]:
- return {
- "id": "resp_test123",
- "object": "response",
- "created_at": 1234567890,
- "status": "completed",
- "model": "gpt-4.1",
- "output": [
- {
- "type": "message",
- "id": "msg_test",
- "role": "assistant",
- "content": [{"type": "output_text", "text": "Hello", "annotations": []}],
- }
- ],
- "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
- }
-
-
-TEST_CASES: dict[str, Case] = {
- **COMMON_CASES,
- "assistant_with_reasoning": {
- "history": [
- Message(role="user", content="What is 2+2?"),
- Message(
- role="assistant",
- content=[
- ThinkPart(think="Thinking...", encrypted="enc_abc"),
- TextPart(text="4."),
- ],
- ),
- Message(role="user", content="Thanks!"),
- ],
- },
-}
-
-
-@pytest.mark.asyncio
-async def test_openai_responses_message_conversion():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/responses").mock(return_value=Response(200, json=make_response()))
- provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False)
- results = await run_test_cases(mock, provider, TEST_CASES, ("input", "tools"))
-
- assert results == snapshot(
- {
- "simple_user_message": {
- "input": [
- {"role": "developer", "content": "You are helpful."},
- {
- "content": [{"type": "input_text", "text": "Hello!"}],
- "role": "user",
- "type": "message",
- },
- ],
- "tools": [],
- },
- "multi_turn_conversation": {
- "input": [
- {
- "content": [{"type": "input_text", "text": "What is 2+2?"}],
- "role": "user",
- "type": "message",
- },
- {
- "content": [
- {"type": "output_text", "text": "2+2 equals 4.", "annotations": []}
- ],
- "role": "assistant",
- "type": "message",
- },
- {
- "content": [{"type": "input_text", "text": "And 3+3?"}],
- "role": "user",
- "type": "message",
- },
- ],
- "tools": [],
- },
- "multi_turn_with_system": {
- "input": [
- {"role": "developer", "content": "You are a math tutor."},
- {
- "content": [{"type": "input_text", "text": "What is 2+2?"}],
- "role": "user",
- "type": "message",
- },
- {
- "content": [
- {"type": "output_text", "text": "2+2 equals 4.", "annotations": []}
- ],
- "role": "assistant",
- "type": "message",
- },
- {
- "content": [{"type": "input_text", "text": "And 3+3?"}],
- "role": "user",
- "type": "message",
- },
- ],
- "tools": [],
- },
- "image_url": {
- "input": [
- {
- "content": [
- {"type": "input_text", "text": "What's in this image?"},
- {
- "type": "input_image",
- "detail": "auto",
- "image_url": "https://example.com/image.png",
- },
- ],
- "role": "user",
- "type": "message",
- }
- ],
- "tools": [],
- },
- "tool_definition": {
- "input": [
- {
- "content": [{"type": "input_text", "text": "Add 2 and 3"}],
- "role": "user",
- "type": "message",
- }
- ],
- "tools": [
- {
- "type": "function",
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {
- "type": "integer",
- "description": "First number",
- },
- "b": {
- "type": "integer",
- "description": "Second number",
- },
- },
- "required": ["a", "b"],
- },
- "strict": False,
- },
- {
- "type": "function",
- "name": "multiply",
- "description": "Multiply two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- "strict": False,
- },
- ],
- },
- "tool_call_with_image": {
- "input": [
- {
- "content": [{"type": "input_text", "text": "Add 2 and 3"}],
- "role": "user",
- "type": "message",
- },
- {
- "content": [
- {
- "type": "output_text",
- "text": "I'll add those numbers for you.",
- "annotations": [],
- }
- ],
- "role": "assistant",
- "type": "message",
- },
- {
- "arguments": '{"a": 2, "b": 3}',
- "call_id": "call_abc123",
- "name": "add",
- "type": "function_call",
- },
- {
- "call_id": "call_abc123",
- "output": [
- {"type": "input_text", "text": "5"},
- {
- "type": "input_image",
- "image_url": "https://example.com/image.png",
- },
- ],
- "type": "function_call_output",
- },
- ],
- "tools": [],
- },
- "tool_call": {
- "input": [
- {
- "content": [{"type": "input_text", "text": "Add 2 and 3"}],
- "role": "user",
- "type": "message",
- },
- {
- "content": [
- {
- "type": "output_text",
- "text": "I'll add those numbers for you.",
- "annotations": [],
- }
- ],
- "role": "assistant",
- "type": "message",
- },
- {
- "arguments": '{"a": 2, "b": 3}',
- "call_id": "call_abc123",
- "name": "add",
- "type": "function_call",
- },
- {
- "call_id": "call_abc123",
- "output": [{"type": "input_text", "text": "5"}],
- "type": "function_call_output",
- },
- ],
- "tools": [],
- },
- "parallel_tool_calls": {
- "input": [
- {
- "content": [{"type": "input_text", "text": "Calculate 2+3 and 4*5"}],
- "role": "user",
- "type": "message",
- },
- {
- "content": [
- {
- "type": "output_text",
- "text": "I'll calculate both.",
- "annotations": [],
- }
- ],
- "role": "assistant",
- "type": "message",
- },
- {
- "arguments": '{"a": 2, "b": 3}',
- "call_id": "call_add",
- "name": "add",
- "type": "function_call",
- },
- {
- "arguments": '{"a": 4, "b": 5}',
- "call_id": "call_mul",
- "name": "multiply",
- "type": "function_call",
- },
- {
- "call_id": "call_add",
- "output": [
- {
- "type": "input_text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "input_text", "text": "5"},
- ],
- "type": "function_call_output",
- },
- {
- "call_id": "call_mul",
- "output": [
- {
- "type": "input_text",
- "text": "This is a system reminder"
- "",
- },
- {"type": "input_text", "text": "20"},
- ],
- "type": "function_call_output",
- },
- ],
- "tools": [
- {
- "type": "function",
- "name": "add",
- "description": "Add two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- "strict": False,
- },
- {
- "type": "function",
- "name": "multiply",
- "description": "Multiply two integers.",
- "parameters": {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "First number"},
- "b": {"type": "integer", "description": "Second number"},
- },
- "required": ["a", "b"],
- },
- "strict": False,
- },
- ],
- },
- "assistant_with_reasoning": {
- "input": [
- {
- "content": [{"type": "input_text", "text": "What is 2+2?"}],
- "role": "user",
- "type": "message",
- },
- {
- "summary": [{"type": "summary_text", "text": "Thinking..."}],
- "type": "reasoning",
- "encrypted_content": "enc_abc",
- },
- {
- "content": [
- {
- "type": "output_text",
- "text": "4.",
- "annotations": [],
- }
- ],
- "role": "assistant",
- "type": "message",
- },
- {
- "content": [{"type": "input_text", "text": "Thanks!"}],
- "role": "user",
- "type": "message",
- },
- ],
- "tools": [],
- },
- }
- )
-
-
-@pytest.mark.asyncio
-async def test_openai_responses_generation_kwargs():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/responses").mock(return_value=Response(200, json=make_response()))
- provider = OpenAIResponses(
- model="gpt-4.1", api_key="test-key", stream=False
- ).with_generation_kwargs(temperature=0.7, max_output_tokens=2048)
- stream = await provider.generate("", [], [Message(role="user", content="Hi")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert (body["temperature"], body["max_output_tokens"]) == snapshot((0.7, 2048))
-
-
-@pytest.mark.asyncio
-async def test_openai_responses_with_thinking():
- with respx.mock(base_url="https://api.openai.com") as mock:
- mock.post("/v1/responses").mock(return_value=Response(200, json=make_response()))
- provider = OpenAIResponses(model="gpt-4.1", api_key="test-key", stream=False).with_thinking(
- "high"
- )
- stream = await provider.generate("", [], [Message(role="user", content="Think")])
- async for _ in stream:
- pass
- body = json.loads(mock.calls.last.request.content.decode())
- assert body["reasoning"] == snapshot({"effort": "high", "summary": "auto"})
diff --git a/tests/test_chat_provider.py b/tests/test_chat_provider.py
deleted file mode 100644
index 339b702..0000000
--- a/tests/test_chat_provider.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import asyncio
-
-import pytest
-
-from kosong.chat_provider import APIStatusError, StreamedMessagePart
-from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig
-from kosong.chat_provider.kimi import Kimi
-from kosong.chat_provider.mock import MockChatProvider
-from kosong.message import Message, TextPart
-
-
-def test_mock_chat_provider():
- input_parts: list[StreamedMessagePart] = [
- TextPart(text="Hello, world!"),
- ]
-
- async def generate() -> list[StreamedMessagePart]:
- chat_provider = MockChatProvider(message_parts=input_parts)
- parts: list[StreamedMessagePart] = []
- async for part in await chat_provider.generate(system_prompt="", tools=[], history=[]):
- parts.append(part)
- return parts
-
- output_parts = asyncio.run(generate())
- assert output_parts == input_parts
-
-
-@pytest.mark.asyncio
-async def test_chaos_chat_provider():
- base = Kimi(model="dummy", api_key="sk-1234567890")
- chat_provider = ChaosChatProvider(
- base,
- chaos_config=ChaosConfig(error_probability=1.0),
- )
- for _ in range(3):
- try:
- parts: list[StreamedMessagePart] = []
- async for part in await chat_provider.generate(
- system_prompt="",
- tools=[],
- history=[Message(role="user", content=[TextPart(text="Hello, world!")])],
- ):
- parts.append(part)
- raise AssertionError("Expected APIStatusError")
- except APIStatusError:
- pass
diff --git a/tests/test_context.py b/tests/test_context.py
deleted file mode 100644
index e4f7d73..0000000
--- a/tests/test_context.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import asyncio
-from pathlib import Path
-
-from kosong.contrib.context.linear import JsonlLinearStorage, LinearContext, MemoryLinearStorage
-from kosong.message import Message
-
-
-def test_linear_context():
- context = LinearContext(
- storage=MemoryLinearStorage(),
- )
- assert context.history == []
-
- async def run():
- await context.add_message(Message(role="user", content="abc"))
- await context.add_message(Message(role="assistant", content="def"))
- return context.history
-
- history = asyncio.run(run())
- assert history == [
- Message(role="user", content="abc"),
- Message(role="assistant", content="def"),
- ]
-
-
-def test_linear_context_with_jsonl_storage():
- test_path = Path(__file__).parent / "test.jsonl"
- if test_path.exists():
- test_path.unlink()
-
- async def run():
- storage = JsonlLinearStorage(path=test_path)
- context = LinearContext(
- storage=storage,
- )
- await context.add_message(Message(role="user", content="abc"))
- await context.add_message(Message(role="assistant", content="def"))
- return context.history
-
- history = asyncio.run(run())
- assert history == [
- Message(role="user", content="abc"),
- Message(role="assistant", content="def"),
- ]
-
- with open(test_path) as f:
- expected = """\
-{"role":"user","content":"abc"}
-{"role":"assistant","content":"def"}
-"""
- assert f.read() == expected
-
- test_path.unlink()
diff --git a/tests/test_echo_chat_provider.py b/tests/test_echo_chat_provider.py
deleted file mode 100644
index 98b7201..0000000
--- a/tests/test_echo_chat_provider.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import pytest
-
-from kosong import generate
-from kosong.chat_provider import ChatProviderError, StreamedMessagePart, TokenUsage
-from kosong.chat_provider.echo import EchoChatProvider
-from kosong.message import (
- AudioURLPart,
- ImageURLPart,
- Message,
- TextPart,
- ThinkPart,
- ToolCall,
- ToolCallPart,
-)
-
-
-@pytest.mark.asyncio
-async def test_echo_chat_provider_streams_parts():
- dsl = "\n".join(
- [
- "id: echo-42",
- 'usage: {"input_other": 10, "output": 2, "input_cache_read": 3}',
- "text: Hello,",
- "text: world!",
- "think: thinking...",
- 'image_url: {"url": "https://example.com/image.png", "id": "img-1"}',
- "audio_url: https://example.com/audio.mp3",
- (
- 'tool_call: {"id": "call-1", "name": "search", '
- '"arguments": "{\\"q\\":\\"python\\"", "extras": {"source": "test"}}'
- ),
- 'tool_call_part: {"arguments_part": "}"}',
- ]
- )
-
- provider = EchoChatProvider()
- history = [Message(role="user", content=dsl)]
-
- parts: list[StreamedMessagePart] = []
- stream = await provider.generate(system_prompt="", tools=[], history=history)
- async for part in stream:
- parts.append(part)
-
- assert stream.id == "echo-42"
- assert stream.usage == TokenUsage(
- input_other=10,
- output=2,
- input_cache_read=3,
- input_cache_creation=0,
- )
- assert parts == [
- TextPart(text="Hello,"),
- TextPart(text=" world!"),
- ThinkPart(think="thinking...", encrypted=None),
- ImageURLPart(
- image_url=ImageURLPart.ImageURL(url="https://example.com/image.png", id="img-1")
- ),
- AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3", id=None)),
- ToolCall(
- id="call-1",
- function=ToolCall.FunctionBody(name="search", arguments='{"q":"python"'),
- extras={"source": "test"},
- ),
- ToolCallPart(arguments_part="}"),
- ]
-
-
-@pytest.mark.asyncio
-async def test_echo_chat_provider_with_generate_merge_tool_call():
- dsl = """
- text: Hello
- tool_call: {"id": "tc-1", "name": "get_weather", "arguments": null}
- tool_call_part: {"arguments_part": "{"}
- tool_call_part: {"arguments_part": "\\"city\\":\\"Hangzhou\\""}
- tool_call_part: {"arguments_part": "}"}
- tool_call_part:
- """
-
- provider = EchoChatProvider()
- history = [Message(role="user", content=dsl)]
-
- result = await generate(
- chat_provider=provider,
- system_prompt="",
- tools=[],
- history=history,
- )
- message = result.message
-
- assert message.content == [TextPart(text="Hello")]
- assert message.tool_calls == [
- ToolCall(
- id="tc-1",
- function=ToolCall.FunctionBody(name="get_weather", arguments='{"city":"Hangzhou"}'),
- )
- ]
- assert result.usage is None
-
-
-@pytest.mark.asyncio
-async def test_echo_chat_provider_rejects_non_string_arguments():
- dsl = """
- tool_call: {"id": "call-1", "name": "search", "arguments": {"q": "python"}}
- """
- provider = EchoChatProvider()
- history = [Message(role="user", content=dsl)]
-
- with pytest.raises(ChatProviderError):
- await provider.generate(system_prompt="", tools=[], history=history)
-
-
-@pytest.mark.asyncio
-async def test_echo_chat_provider_requires_user_message():
- provider = EchoChatProvider()
- history = [Message(role="tool", content="tool output")]
-
- with pytest.raises(ChatProviderError):
- await provider.generate(system_prompt="", tools=[], history=history)
-
-
-@pytest.mark.asyncio
-async def test_echo_chat_provider_requires_dsl_content():
- provider = EchoChatProvider()
- history = [Message(role="user", content="")]
-
- with pytest.raises(ChatProviderError):
- await provider.generate(system_prompt="", tools=[], history=history)
diff --git a/tests/test_generate.py b/tests/test_generate.py
deleted file mode 100644
index 1a46904..0000000
--- a/tests/test_generate.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import asyncio
-from copy import deepcopy
-
-from kosong import generate
-from kosong.chat_provider import StreamedMessagePart
-from kosong.chat_provider.mock import MockChatProvider
-from kosong.message import ImageURLPart, TextPart, ToolCall, ToolCallPart
-
-
-def test_generate():
- chat_provider = MockChatProvider(
- message_parts=[
- TextPart(text="Hello, "),
- TextPart(text="world"),
- TextPart(text="!"),
- ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")),
- TextPart(text="Another text."),
- TextPart(text=""),
- ToolCall(
- id="get_weather#123",
- function=ToolCall.FunctionBody(name="get_weather", arguments=None),
- ),
- ToolCallPart(arguments_part="{"),
- ToolCallPart(arguments_part='"city":'),
- ToolCallPart(arguments_part='"Beijing"'),
- ToolCallPart(arguments_part="}"),
- ToolCallPart(arguments_part=None),
- ]
- )
- message = asyncio.run(generate(chat_provider, system_prompt="", tools=[], history=[])).message
- assert message.content == [
- TextPart(text="Hello, world!"),
- ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")),
- TextPart(text="Another text."),
- ]
- assert message.tool_calls == [
- ToolCall(
- id="get_weather#123",
- function=ToolCall.FunctionBody(name="get_weather", arguments='{"city":"Beijing"}'),
- ),
- ]
-
-
-def test_generate_with_callbacks():
- input_parts: list[StreamedMessagePart] = [
- TextPart(text="Hello, "),
- TextPart(text="world"),
- TextPart(text="!"),
- ToolCall(
- id="get_weather#123",
- function=ToolCall.FunctionBody(name="get_weather", arguments=None),
- ),
- ToolCallPart(arguments_part="{"),
- ToolCallPart(arguments_part='"city":'),
- ToolCallPart(arguments_part='"Beijing"'),
- ToolCallPart(arguments_part="}"),
- ToolCall(
- id="get_time#123",
- function=ToolCall.FunctionBody(name="get_time", arguments=""),
- ),
- ]
- chat_provider = MockChatProvider(message_parts=deepcopy(input_parts))
-
- output_parts: list[StreamedMessagePart] = []
- output_tool_calls: list[ToolCall] = []
-
- async def on_message_part(part: StreamedMessagePart):
- output_parts.append(part)
-
- async def on_tool_call(tool_call: ToolCall):
- output_tool_calls.append(tool_call)
-
- message = asyncio.run(
- generate(
- chat_provider,
- system_prompt="",
- tools=[],
- history=[],
- on_message_part=on_message_part,
- on_tool_call=on_tool_call,
- )
- ).message
- assert output_parts == input_parts
- assert output_tool_calls == message.tool_calls
diff --git a/tests/test_json_schema_deref.py b/tests/test_json_schema_deref.py
deleted file mode 100644
index c3099fc..0000000
--- a/tests/test_json_schema_deref.py
+++ /dev/null
@@ -1,148 +0,0 @@
-from __future__ import annotations
-
-from typing import Literal
-
-from inline_snapshot import snapshot
-from pydantic import BaseModel, Field
-
-from kosong.utils.jsonschema import deref_json_schema
-from kosong.utils.typing import JsonType
-
-JsonSchema = dict[str, JsonType]
-
-
-def test_no_ref():
- class Params(BaseModel):
- id: str = Field(description="The ID of the action.")
- action: str = Field(description="The action to be performed.")
-
- resolved = deref_json_schema(Params.model_json_schema())
- assert resolved == snapshot(
- {
- "properties": {
- "id": {"description": "The ID of the action.", "title": "Id", "type": "string"},
- "action": {
- "description": "The action to be performed.",
- "title": "Action",
- "type": "string",
- },
- },
- "required": ["id", "action"],
- "title": "Params",
- "type": "object",
- }
- )
-
-
-def test_simple_ref():
- class Todo(BaseModel):
- title: str = Field(description="The title of the todo item.")
- status: Literal["pending", "completed"] = Field(description="The status of the todo item.")
-
- class Params(BaseModel):
- todos: list[Todo] = Field(description="A list of todo items.")
-
- resolved = deref_json_schema(Params.model_json_schema())
- assert resolved == snapshot(
- {
- "properties": {
- "todos": {
- "description": "A list of todo items.",
- "items": {
- "properties": {
- "title": {
- "description": "The title of the todo item.",
- "title": "Title",
- "type": "string",
- },
- "status": {
- "description": "The status of the todo item.",
- "enum": ["pending", "completed"],
- "title": "Status",
- "type": "string",
- },
- },
- "required": ["title", "status"],
- "title": "Todo",
- "type": "object",
- },
- "title": "Todos",
- "type": "array",
- }
- },
- "required": ["todos"],
- "title": "Params",
- "type": "object",
- }
- )
-
-
-def test_nested_ref():
- class Address(BaseModel):
- street: str = Field(description="The street address.")
- city: str = Field(description="The city.")
- zip_code: str = Field(description="The ZIP code.")
-
- class User(BaseModel):
- name: str = Field(description="The name of the user.")
- email: str = Field(description="The email of the user.")
- address: Address = Field(description="The address of the user.")
-
- class Params(BaseModel):
- users: list[User] = Field(description="A list of users.")
-
- resolved = deref_json_schema(Params.model_json_schema())
- assert resolved == snapshot(
- {
- "properties": {
- "users": {
- "description": "A list of users.",
- "items": {
- "properties": {
- "name": {
- "description": "The name of the user.",
- "title": "Name",
- "type": "string",
- },
- "email": {
- "description": "The email of the user.",
- "title": "Email",
- "type": "string",
- },
- "address": {
- "description": "The address of the user.",
- "properties": {
- "street": {
- "description": "The street address.",
- "title": "Street",
- "type": "string",
- },
- "city": {
- "description": "The city.",
- "title": "City",
- "type": "string",
- },
- "zip_code": {
- "description": "The ZIP code.",
- "title": "Zip Code",
- "type": "string",
- },
- },
- "required": ["street", "city", "zip_code"],
- "title": "Address",
- "type": "object",
- },
- },
- "required": ["name", "email", "address"],
- "title": "User",
- "type": "object",
- },
- "title": "Users",
- "type": "array",
- }
- },
- "required": ["users"],
- "title": "Params",
- "type": "object",
- }
- )
diff --git a/tests/test_message.py b/tests/test_message.py
deleted file mode 100644
index 5309d89..0000000
--- a/tests/test_message.py
+++ /dev/null
@@ -1,286 +0,0 @@
-from inline_snapshot import snapshot
-
-from kosong.message import AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart, ToolCall
-
-
-def test_plain_text_message():
- message = Message(role="user", content="Hello, world!")
- dumped = message.model_dump(exclude_none=True)
- assert dumped == snapshot({"role": "user", "content": "Hello, world!"})
- assert Message.model_validate(dumped) == message
-
-
-def test_message_with_single_part():
- message = Message(
- role="assistant",
- content=ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")),
- )
- dumped = message.model_dump(exclude_none=True)
- assert dumped == snapshot(
- {
- "role": "assistant",
- "content": [
- {
- "type": "image_url",
- "image_url": {"url": "https://example.com/image.png", "id": None},
- }
- ],
- }
- )
- assert Message.model_validate(dumped) == message
-
-
-def test_message_with_tool_calls():
- message = Message(
- role="assistant",
- content=[TextPart(text="Hello, world!")],
- tool_calls=[
- ToolCall(id="123", function=ToolCall.FunctionBody(name="function", arguments="{}"))
- ],
- )
- dumped = message.model_dump(exclude_none=True)
- assert dumped == snapshot(
- {
- "role": "assistant",
- "content": "Hello, world!",
- "tool_calls": [
- {
- "type": "function",
- "id": "123",
- "function": {"name": "function", "arguments": "{}"},
- }
- ],
- }
- )
- assert Message.model_validate(dumped) == message
-
-
-def test_message_with_no_content():
- message = Message(
- role="assistant",
- content=[],
- tool_calls=[
- ToolCall(id="123", function=ToolCall.FunctionBody(name="function", arguments="{}"))
- ],
- )
-
- assert message.model_dump(exclude_none=True) == snapshot(
- {
- "role": "assistant",
- "content": [],
- "tool_calls": [
- {
- "type": "function",
- "id": "123",
- "function": {"name": "function", "arguments": "{}"},
- }
- ],
- }
- )
-
-
-def test_message_with_complex_content():
- message = Message(
- role="user",
- content=[
- TextPart(text="Hello, world!"),
- ThinkPart(think="I think I need to think about this."),
- ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")),
- AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")),
- ],
- tool_calls=[
- ToolCall(id="123", function=ToolCall.FunctionBody(name="function", arguments="{}")),
- ],
- )
- dumped = message.model_dump(exclude_none=True)
- assert dumped == snapshot(
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "Hello, world!"},
- {
- "type": "think",
- "think": "I think I need to think about this.",
- "encrypted": None,
- },
- {
- "type": "image_url",
- "image_url": {"url": "https://example.com/image.png", "id": None},
- },
- {
- "type": "audio_url",
- "audio_url": {"url": "https://example.com/audio.mp3", "id": None},
- },
- ],
- "tool_calls": [
- {
- "type": "function",
- "id": "123",
- "function": {"name": "function", "arguments": "{}"},
- }
- ],
- }
- )
- assert Message.model_validate(dumped) == message
-
-
-def test_deserialize_from_json_plain_text():
- data = {
- "role": "user",
- "content": "Hello, world!",
- }
- message = Message.model_validate(data)
- assert message == snapshot(Message(role="user", content=[TextPart(text="Hello, world!")]))
-
-
-def test_deserialize_from_json_with_content_and_tool_calls():
- data = {
- "role": "assistant",
- "content": [
- {
- "type": "text",
- "text": "Hello, world!",
- }
- ],
- "tool_calls": [
- {
- "type": "function",
- "id": "tc_123",
- "function": {"name": "do_something", "arguments": '{"x":1}'},
- }
- ],
- }
- message = Message.model_validate(data)
- assert message == snapshot(
- Message(
- role="assistant",
- content=[TextPart(text="Hello, world!")],
- tool_calls=[
- ToolCall(
- id="tc_123",
- function=ToolCall.FunctionBody(name="do_something", arguments='{"x":1}'),
- )
- ],
- )
- )
-
-
-def test_deserialize_from_json_none_content_with_tool_calls():
- data = {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "type": "function",
- "id": "tc_456",
- "function": {"name": "do_other", "arguments": "{}"},
- }
- ],
- }
- message = Message.model_validate(data)
- assert message == snapshot(
- Message(
- role="assistant",
- content=[],
- tool_calls=[
- ToolCall(
- id="tc_456", function=ToolCall.FunctionBody(name="do_other", arguments="{}")
- )
- ],
- )
- )
-
-
-def test_deserialize_from_json_with_content_but_no_tool_calls():
- data = {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Only content, no tools.",
- }
- ],
- }
- message = Message.model_validate(data)
- assert message == snapshot(
- Message(role="user", content=[TextPart(text="Only content, no tools.")])
- )
-
-
-def test_message_with_empty_list_content():
- """Test that content=[] serializes to None and deserializes back to []."""
- # Create message with empty list content
- message = Message(role="assistant", content=[])
-
- # Serialize - empty list should become None
- dumped = message.model_dump()
- assert dumped == snapshot(
- {
- "role": "assistant",
- "name": None,
- "content": [],
- "tool_calls": None,
- "tool_call_id": None,
- "partial": None,
- }
- )
-
- # Deserialize back - None should become empty list
- assert Message.model_validate(dumped) == snapshot(Message(role="assistant", content=[]))
-
- # Test with tool_calls
- message_with_tools = Message(
- role="assistant",
- content=[],
- tool_calls=[
- ToolCall(id="123", function=ToolCall.FunctionBody(name="test_func", arguments="{}"))
- ],
- )
- dumped = message_with_tools.model_dump()
- assert dumped == snapshot(
- {
- "role": "assistant",
- "name": None,
- "content": [],
- "tool_calls": [
- {
- "type": "function",
- "id": "123",
- "function": {"name": "test_func", "arguments": "{}"},
- "extras": None,
- }
- ],
- "tool_call_id": None,
- "partial": None,
- }
- )
- assert Message.model_validate(dumped) == snapshot(
- Message(
- role="assistant",
- content=[],
- tool_calls=[
- ToolCall(id="123", function=ToolCall.FunctionBody(name="test_func", arguments="{}"))
- ],
- )
- )
-
-
-def test_message_extract_text():
- message = Message(
- role="user",
- content=[
- TextPart(text="Hello, "),
- TextPart(text="world"),
- ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")),
- TextPart(text="!"),
- ThinkPart(think="This is a thought."),
- ],
- )
- extracted_text = message.extract_text()
- assert extracted_text == snapshot("Hello, world!")
- extracted_text = message.extract_text(sep="\n")
- assert extracted_text == snapshot("""\
-Hello, \n\
-world
-!\
-""")
diff --git a/tests/test_step.py b/tests/test_step.py
deleted file mode 100644
index 55f3363..0000000
--- a/tests/test_step.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import asyncio
-from typing import override
-
-from kosong import step
-from kosong.chat_provider import StreamedMessagePart
-from kosong.chat_provider.mock import MockChatProvider
-from kosong.message import TextPart, ToolCall
-from kosong.tooling import CallableTool, ParametersType, ToolOk, ToolResult, ToolReturnValue
-from kosong.tooling.simple import SimpleToolset
-
-
-def test_step():
- class PlusTool(CallableTool):
- name: str = "plus"
- description: str = "This is a plus tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {
- "a": {"type": "integer"},
- "b": {"type": "integer"},
- },
- }
-
- @override
- async def __call__(self, a: int, b: int) -> ToolReturnValue:
- return ToolOk(output=str(a + b))
-
- plus_tool_call = ToolCall(
- id="plus#123",
- function=ToolCall.FunctionBody(name="plus", arguments='{"a": 1, "b": 2}'),
- )
- input_parts: list[StreamedMessagePart] = [
- TextPart(text="Hello, world!"),
- plus_tool_call,
- ]
- chat_provider = MockChatProvider(message_parts=input_parts)
- toolset = SimpleToolset([PlusTool()])
-
- output_parts: list[StreamedMessagePart] = []
- collected_tool_results: list[ToolResult] = []
-
- def on_message_part(part: StreamedMessagePart):
- output_parts.append(part)
-
- def on_tool_result(result: ToolResult):
- collected_tool_results.append(result)
-
- async def run():
- step_result = await step(
- chat_provider,
- system_prompt="",
- toolset=toolset,
- history=[],
- on_message_part=on_message_part,
- on_tool_result=on_tool_result,
- )
- tool_results = await step_result.tool_results()
- return step_result, tool_results
-
- step_result, tool_results = asyncio.run(run())
- assert step_result.message.content == [TextPart(text="Hello, world!")]
- assert step_result.tool_calls == [plus_tool_call]
- assert output_parts == input_parts
- assert tool_results == [ToolResult(tool_call_id="plus#123", return_value=ToolOk(output="3"))]
- assert collected_tool_results == tool_results
diff --git a/tests/test_tool_call.py b/tests/test_tool_call.py
deleted file mode 100644
index 5670953..0000000
--- a/tests/test_tool_call.py
+++ /dev/null
@@ -1,294 +0,0 @@
-import asyncio
-import json
-from typing import override
-
-from inline_snapshot import snapshot
-from pydantic import BaseModel, Field
-
-from kosong.message import ToolCall
-from kosong.tooling import (
- BriefDisplayBlock,
- CallableTool,
- CallableTool2,
- ParametersType,
- ToolError,
- ToolOk,
- ToolResult,
- ToolResultFuture,
- ToolReturnValue,
-)
-from kosong.tooling.error import (
- ToolNotFoundError,
- ToolParseError,
- ToolRuntimeError,
- ToolValidateError,
-)
-from kosong.tooling.simple import SimpleToolset
-
-
-def test_callable_tool_int_argument():
- class TestTool(CallableTool):
- name: str = "test"
- description: str = "This is a test tool"
- parameters: ParametersType = {
- "type": "integer",
- }
-
- @override
- async def __call__(self, test: int) -> ToolReturnValue:
- return ToolOk(output=f"Test tool called with {test}")
-
- tool = TestTool()
- assert asyncio.run(tool.call(1)) == ToolOk(output="Test tool called with 1")
-
-
-def test_callable_tool_list_argument():
- class TestTool(CallableTool):
- name: str = "test"
- description: str = "This is a test tool"
- parameters: ParametersType = {
- "type": "array",
- "items": {
- "type": "string",
- },
- }
-
- @override
- async def __call__(self, a: str, b: str) -> ToolReturnValue:
- return ToolOk(output="Test tool called with a and b")
-
- tool = TestTool()
- assert asyncio.run(tool.call(["a", "b"])) == ToolOk(output="Test tool called with a and b")
-
-
-def test_callable_tool_dict_argument():
- class TestTool(CallableTool):
- name: str = "test"
- description: str = "This is a test tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {
- "a": {"type": "string"},
- "b": {"type": "integer"},
- },
- }
-
- @override
- async def __call__(self, a: str, b: int) -> ToolReturnValue:
- return ToolOk(output=f"Test tool called with {a} and {b}")
-
- tool = TestTool()
- assert asyncio.run(tool.call({"a": "a", "b": 1})) == ToolOk(
- output="Test tool called with a and 1"
- )
-
-
-def test_simple_toolset():
- class PlusTool(CallableTool):
- name: str = "plus"
- description: str = "This is a plus tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {
- "a": {"type": "integer"},
- "b": {"type": "integer"},
- },
- "required": ["a", "b"],
- }
-
- @override
- async def __call__(self, a: int, b: int) -> ToolReturnValue:
- return ToolOk(output=str(a + b))
-
- class CompareTool(CallableTool):
- name: str = "compare"
- description: str = "This is a compare tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {
- "a": {"type": "integer"},
- "b": {"type": "integer"},
- },
- "required": ["a", "b"],
- }
-
- @override
- async def __call__(self, a: int, b: int) -> ToolReturnValue:
- return ToolOk(output="greater" if a > b else "less" if a < b else "equal")
-
- class RaiseTool(CallableTool):
- name: str = "raise"
- description: str = "This is a raise tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {},
- }
-
- @override
- async def __call__(self) -> ToolReturnValue:
- raise Exception("test exception")
-
- class ErrorTool(CallableTool):
- name: str = "error"
- description: str = "This is a error tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {},
- }
-
- @override
- async def __call__(self) -> ToolReturnValue:
- return ToolError(message="test error", brief="Error")
-
- class InvalidReturnTypeTool(CallableTool):
- name: str = "invalid_return_type"
- description: str = "This is a invalid return type tool"
- parameters: ParametersType = {
- "type": "object",
- "properties": {},
- }
-
- @override
- async def __call__(self) -> str: # type: ignore[reportIncompatibleMethodOverride]
- return "invalid return type"
-
- toolset = SimpleToolset([PlusTool()])
- toolset += CompareTool()
- toolset += RaiseTool()
- toolset.add(ErrorTool())
- assert toolset.tools[0].name == "plus"
- assert toolset.tools[1].name == "compare"
- assert toolset.tools[2].name == "raise"
- assert toolset.tools[3].name == "error"
-
- try:
- toolset += InvalidReturnTypeTool()
- except TypeError as e:
- assert str(e) == (
- "Expected tool `invalid_return_type` to return `ToolReturnValue`, "
- "but got ``"
- )
- else:
- raise AssertionError("Expected TypeError")
-
- tool_calls = [
- ToolCall(
- id="1",
- function=ToolCall.FunctionBody(
- name="plus",
- arguments=json.dumps({"a": 1, "b": 2}),
- ),
- ),
- ToolCall(
- id="2",
- function=ToolCall.FunctionBody(
- name="compare",
- arguments='{"a": 1, b: 2}',
- ),
- ),
- ToolCall(
- id="3",
- function=ToolCall.FunctionBody(
- name="plus",
- arguments='{"a": 1}',
- ),
- ),
- ToolCall(
- id="4",
- function=ToolCall.FunctionBody(
- name="raise",
- arguments=None,
- ),
- ),
- ToolCall(
- id="5",
- function=ToolCall.FunctionBody(
- name="not_found",
- arguments=None,
- ),
- ),
- ToolCall(
- id="6",
- function=ToolCall.FunctionBody(
- name="error",
- arguments=None,
- ),
- ),
- ]
-
- async def run() -> list[ToolResult]:
- futures: list[ToolResultFuture] = []
- for tool_call in tool_calls:
- result = toolset.handle(tool_call)
- if isinstance(result, ToolResult):
- future = ToolResultFuture()
- future.set_result(result)
- futures.append(future)
- else:
- futures.append(result)
- return await asyncio.gather(*futures)
-
- results = asyncio.run(run())
- assert results[0].tool_call_id == "1"
- assert results[0].return_value == ToolOk(output="3")
- assert isinstance(results[1].return_value, ToolParseError)
- assert isinstance(results[2].return_value, ToolValidateError)
- assert isinstance(results[3].return_value, ToolRuntimeError)
- assert isinstance(results[4].return_value, ToolNotFoundError)
- assert isinstance(results[5].return_value, ToolError)
- assert results[5].return_value.message == "test error"
- assert results[5].return_value.display == snapshot([BriefDisplayBlock(text="Error")])
-
-
-def test_callable_tool_2():
- class TestParams(BaseModel):
- a: int = Field(description="The first argument")
- b: int = Field(default=0, description="The second argument")
- c: str = Field(default="", alias="-c", description="The third argument")
-
- class TestTool(CallableTool2[TestParams]):
- name: str = "test"
- description: str = "This is a test tool"
- params: type[TestParams] = TestParams
-
- @override
- async def __call__(self, params: TestParams) -> ToolReturnValue:
- return ToolOk(output=f"Test tool called with {params.a} and {params.b}")
-
- tool = TestTool()
- assert tool.base.name == "test"
- assert tool.base.description == "This is a test tool"
- assert tool.base.parameters == {
- "type": "object",
- "properties": {
- "a": {"type": "integer", "description": "The first argument"},
- "b": {"type": "integer", "description": "The second argument", "default": 0},
- "-c": {"type": "string", "description": "The third argument", "default": ""},
- },
- "required": ["a"],
- }
-
- assert asyncio.run(tool.call({"a": 1, "b": 2})) == ToolOk(
- output="Test tool called with 1 and 2"
- )
- assert asyncio.run(tool.call({"a": 1})) == ToolOk(output="Test tool called with 1 and 0")
- assert isinstance(asyncio.run(tool.call({"b": 2})), ToolValidateError)
-
-
-def test_simple_toolset_sub():
- class TestParams(BaseModel):
- pass
-
- class TestTool(CallableTool2[TestParams]):
- name: str = "test"
- description: str = "This is a test tool"
- params: type[TestParams] = TestParams
-
- @override
- async def __call__(self, params: TestParams) -> ToolReturnValue:
- return ToolOk(output="Test tool called")
-
- toolset = SimpleToolset([TestTool()])
- assert len(toolset.tools) == 1
- toolset.remove(TestTool.name)
- assert len(toolset.tools) == 0
diff --git a/tests/test_tool_result.py b/tests/test_tool_result.py
deleted file mode 100644
index 87d3846..0000000
--- a/tests/test_tool_result.py
+++ /dev/null
@@ -1,156 +0,0 @@
-from inline_snapshot import snapshot
-
-from kosong.message import ImageURLPart, TextPart
-from kosong.tooling import (
- BriefDisplayBlock,
- ToolError,
- ToolOk,
- ToolReturnValue,
- UnknownDisplayBlock,
-)
-from kosong.tooling.error import ToolNotFoundError
-
-
-def test_tool_return_value():
- ret = ToolReturnValue(
- is_error=False,
- output=[
- TextPart(type="text", text="output text"),
- ImageURLPart(
- type="image_url",
- image_url=ImageURLPart.ImageURL(url="https://example.com/image.png"),
- ),
- ],
- message="This is a successful tool call.",
- display=[
- BriefDisplayBlock(text="a brief msg for user"),
- ],
- extras={"key1": "value1", "key2": 42},
- )
- dump = ret.model_dump(mode="json", exclude_none=True)
- assert dump == snapshot(
- {
- "is_error": False,
- "output": [
- {"type": "text", "text": "output text"},
- {
- "type": "image_url",
- "image_url": {"url": "https://example.com/image.png"},
- },
- ],
- "message": "This is a successful tool call.",
- "display": [{"type": "brief", "text": "a brief msg for user"}],
- "extras": {"key1": "value1", "key2": 42},
- }
- )
-
- assert ToolReturnValue.model_validate(dump) == ret
-
-
-def test_tool_ok():
- ret = ToolOk(
- output="output text",
- message="This is a successful tool call.",
- brief="a brief msg for user",
- )
- assert isinstance(ret, ToolReturnValue)
- assert ret.model_dump(mode="json", exclude_none=True) == snapshot(
- {
- "is_error": False,
- "output": "output text",
- "message": "This is a successful tool call.",
- "display": [{"type": "brief", "text": "a brief msg for user"}],
- }
- )
-
-
-def test_tool_error():
- ret = ToolError(
- message="This is a failed tool call.",
- brief="a brief error msg for user",
- output="error output text",
- )
- assert isinstance(ret, ToolReturnValue)
- assert ret.model_dump(mode="json", exclude_none=True) == snapshot(
- {
- "is_error": True,
- "output": "error output text",
- "message": "This is a failed tool call.",
- "display": [{"type": "brief", "text": "a brief error msg for user"}],
- }
- )
-
-
-def test_tool_ok_with_content_parts():
- ret = ToolOk(
- output=[
- TextPart(type="text", text="output text"),
- ImageURLPart(
- type="image_url",
- image_url=ImageURLPart.ImageURL(url="https://example.com/image.png"),
- ),
- ],
- message="This is a successful tool call.",
- brief="a brief msg for user",
- )
- assert isinstance(ret, ToolReturnValue)
- assert ret.model_dump(mode="json", exclude_none=True) == snapshot(
- {
- "is_error": False,
- "output": [
- {"type": "text", "text": "output text"},
- {
- "type": "image_url",
- "image_url": {"url": "https://example.com/image.png"},
- },
- ],
- "message": "This is a successful tool call.",
- "display": [{"type": "brief", "text": "a brief msg for user"}],
- }
- )
-
-
-def test_tool_error_subclass():
- ret = ToolNotFoundError(tool_name="non_existent_tool")
- assert isinstance(ret, ToolReturnValue)
- assert isinstance(ret, ToolError)
- assert ret.model_dump(mode="json", exclude_none=True) == snapshot(
- {
- "is_error": True,
- "output": "",
- "message": "Tool `non_existent_tool` not found",
- "display": [{"type": "brief", "text": "Tool `non_existent_tool` not found"}],
- }
- )
-
-
-def test_unknown_display_block():
- payload = {
- "is_error": False,
- "output": "ok",
- "message": "done",
- "display": [
- {"type": "fancy", "title": "Hello", "payload": {"a": 1}, "list": [1, 2]},
- ],
- }
- ret = ToolReturnValue.model_validate(payload)
- assert ret.display == snapshot(
- [
- UnknownDisplayBlock(
- type="fancy", data={"title": "Hello", "payload": {"a": 1}, "list": [1, 2]}
- )
- ]
- )
- assert ret.model_dump(mode="json", exclude_none=True) == snapshot(
- {
- "is_error": False,
- "output": "ok",
- "message": "done",
- "display": [
- {
- "type": "fancy",
- "data": {"title": "Hello", "payload": {"a": 1}, "list": [1, 2]},
- }
- ],
- }
- )
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index 96eeaab..0000000
--- a/uv.lock
+++ /dev/null
@@ -1,1364 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.12"
-
-[[package]]
-name = "annotated-types"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
-]
-
-[[package]]
-name = "anthropic"
-version = "0.75.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "docstring-parser" },
- { name = "httpx" },
- { name = "jiter" },
- { name = "pydantic" },
- { name = "sniffio" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" },
-]
-
-[[package]]
-name = "anyio"
-version = "4.10.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "sniffio" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
-]
-
-[[package]]
-name = "asttokens"
-version = "3.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
-]
-
-[[package]]
-name = "attrs"
-version = "25.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
-]
-
-[[package]]
-name = "black"
-version = "25.9.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "mypy-extensions" },
- { name = "packaging" },
- { name = "pathspec" },
- { name = "platformdirs" },
- { name = "pytokens" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" },
- { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" },
- { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" },
- { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" },
- { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" },
- { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" },
- { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" },
- { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" },
- { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" },
-]
-
-[[package]]
-name = "cachetools"
-version = "6.2.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2025.8.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
-]
-
-[[package]]
-name = "cffi"
-version = "2.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
- { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
- { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
- { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
- { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
- { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
- { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
- { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
- { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
- { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
- { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
- { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
- { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
- { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
- { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
- { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
- { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
- { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
- { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
- { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
- { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
- { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
- { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
- { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
- { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
- { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
- { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
- { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
- { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
- { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
- { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
- { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
- { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
- { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
- { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
- { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
- { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
- { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
- { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
- { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
- { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
- { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
- { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
- { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
- { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
- { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
- { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
- { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
- { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
- { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
- { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
- { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
- { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
- { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
- { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
- { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
- { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
- { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
- { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
- { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
- { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
- { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
- { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
- { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
- { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
- { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
- { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
- { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
- { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
- { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
- { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
- { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
- { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
- { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
- { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
- { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "cryptography"
-version = "46.0.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
- { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
- { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
- { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
- { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
- { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
- { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
- { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
- { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
- { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
- { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
- { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
- { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
- { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
- { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
- { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
- { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
- { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
- { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
- { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
- { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
- { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
- { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
- { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
- { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
- { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
- { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
- { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
- { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
- { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
- { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
- { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
- { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
- { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
- { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
- { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
- { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
- { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
- { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
- { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
- { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
- { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
- { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
-]
-
-[[package]]
-name = "distro"
-version = "1.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
-]
-
-[[package]]
-name = "docstring-parser"
-version = "0.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
-]
-
-[[package]]
-name = "executing"
-version = "2.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
-]
-
-[[package]]
-name = "google-auth"
-version = "2.45.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cachetools" },
- { name = "pyasn1-modules" },
- { name = "rsa" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708, upload-time = "2025-12-15T22:58:42.889Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312, upload-time = "2025-12-15T22:58:40.777Z" },
-]
-
-[package.optional-dependencies]
-requests = [
- { name = "requests" },
-]
-
-[[package]]
-name = "google-genai"
-version = "1.56.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "google-auth", extra = ["requests"] },
- { name = "httpx" },
- { name = "pydantic" },
- { name = "requests" },
- { name = "sniffio" },
- { name = "tenacity" },
- { name = "typing-extensions" },
- { name = "websockets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/70/ad/d3ac5a102135bd3f1e4b1475ca65d2bd4bcc22eb2e9348ac40fe3fadb1d6/google_genai-1.56.0.tar.gz", hash = "sha256:0491af33c375f099777ae207d9621f044e27091fafad4c50e617eba32165e82f", size = 340451, upload-time = "2025-12-17T12:35:05.412Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl", hash = "sha256:9e6b11e0c105ead229368cb5849a480e4d0185519f8d9f538d61ecfcf193b052", size = 426563, upload-time = "2025-12-17T12:35:03.717Z" },
-]
-
-[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
-]
-
-[[package]]
-name = "httpcore"
-version = "1.0.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
-]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
- { name = "idna" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
-]
-
-[[package]]
-name = "httpx-sse"
-version = "0.4.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
-]
-
-[[package]]
-name = "inline-snapshot"
-version = "0.31.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asttokens" },
- { name = "executing" },
- { name = "pytest" },
- { name = "rich" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1c/b1/52b5ee59f73ed31d5fe21b10881bf2d121d07d54b23c0b6b74186792e620/inline_snapshot-0.31.1.tar.gz", hash = "sha256:4ea5ed70aa1d652713bbfd750606b94bd8a42483f7d3680433b3e92994495f64", size = 2606338, upload-time = "2025-11-07T07:36:18.932Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/52/945db420380efbda8c69a7a4a16c53df9d7ac50d8217286b9d41e5d825ff/inline_snapshot-0.31.1-py3-none-any.whl", hash = "sha256:7875a73c986a03388c7e758fb5cb8a43d2c3a20328aa1d851bfb4ed536c4496f", size = 71965, upload-time = "2025-11-07T07:36:16.836Z" },
-]
-
-[package.optional-dependencies]
-black = [
- { name = "black" },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
-]
-
-[[package]]
-name = "jiter"
-version = "0.11.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9d/c0/a3bb4cc13aced219dd18191ea66e874266bd8aa7b96744e495e1c733aa2d/jiter-0.11.0.tar.gz", hash = "sha256:1d9637eaf8c1d6a63d6562f2a6e5ab3af946c66037eb1b894e8fad75422266e4", size = 167094, upload-time = "2025-09-15T09:20:38.212Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/b5/3009b112b8f673e568ef79af9863d8309a15f0a8cdcc06ed6092051f377e/jiter-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb7b377688cc3850bbe5c192a6bd493562a0bc50cbc8b047316428fbae00ada", size = 305510, upload-time = "2025-09-15T09:19:25.893Z" },
- { url = "https://files.pythonhosted.org/packages/fe/82/15514244e03b9e71e086bbe2a6de3e4616b48f07d5f834200c873956fb8c/jiter-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1b7cbe3f25bd0d8abb468ba4302a5d45617ee61b2a7a638f63fee1dc086be99", size = 316521, upload-time = "2025-09-15T09:19:27.525Z" },
- { url = "https://files.pythonhosted.org/packages/92/94/7a2e905f40ad2d6d660e00b68d818f9e29fb87ffe82774f06191e93cbe4a/jiter-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0a7f0ec81d5b7588c5cade1eb1925b91436ae6726dc2df2348524aeabad5de6", size = 338214, upload-time = "2025-09-15T09:19:28.727Z" },
- { url = "https://files.pythonhosted.org/packages/a8/9c/5791ed5bdc76f12110158d3316a7a3ec0b1413d018b41c5ed399549d3ad5/jiter-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07630bb46ea2a6b9c6ed986c6e17e35b26148cce2c535454b26ee3f0e8dcaba1", size = 361280, upload-time = "2025-09-15T09:19:30.013Z" },
- { url = "https://files.pythonhosted.org/packages/d4/7f/b7d82d77ff0d2cb06424141000176b53a9e6b16a1125525bb51ea4990c2e/jiter-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7764f27d28cd4a9cbc61704dfcd80c903ce3aad106a37902d3270cd6673d17f4", size = 487895, upload-time = "2025-09-15T09:19:31.424Z" },
- { url = "https://files.pythonhosted.org/packages/42/44/10a1475d46f1fc1fd5cc2e82c58e7bca0ce5852208e0fa5df2f949353321/jiter-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4a6c4a737d486f77f842aeb22807edecb4a9417e6700c7b981e16d34ba7c72", size = 378421, upload-time = "2025-09-15T09:19:32.746Z" },
- { url = "https://files.pythonhosted.org/packages/9a/5f/0dc34563d8164d31d07bc09d141d3da08157a68dcd1f9b886fa4e917805b/jiter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf408d2a0abd919b60de8c2e7bc5eeab72d4dafd18784152acc7c9adc3291591", size = 347932, upload-time = "2025-09-15T09:19:34.612Z" },
- { url = "https://files.pythonhosted.org/packages/f7/de/b68f32a4fcb7b4a682b37c73a0e5dae32180140cd1caf11aef6ad40ddbf2/jiter-0.11.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdef53eda7d18e799625023e1e250dbc18fbc275153039b873ec74d7e8883e09", size = 386959, upload-time = "2025-09-15T09:19:35.994Z" },
- { url = "https://files.pythonhosted.org/packages/76/0a/c08c92e713b6e28972a846a81ce374883dac2f78ec6f39a0dad9f2339c3a/jiter-0.11.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:53933a38ef7b551dd9c7f1064f9d7bb235bb3168d0fa5f14f0798d1b7ea0d9c5", size = 517187, upload-time = "2025-09-15T09:19:37.426Z" },
- { url = "https://files.pythonhosted.org/packages/89/b5/4a283bec43b15aad54fcae18d951f06a2ec3f78db5708d3b59a48e9c3fbd/jiter-0.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:11840d2324c9ab5162fc1abba23bc922124fedcff0d7b7f85fffa291e2f69206", size = 509461, upload-time = "2025-09-15T09:19:38.761Z" },
- { url = "https://files.pythonhosted.org/packages/34/a5/f8bad793010534ea73c985caaeef8cc22dfb1fedb15220ecdf15c623c07a/jiter-0.11.0-cp312-cp312-win32.whl", hash = "sha256:4f01a744d24a5f2bb4a11657a1b27b61dc038ae2e674621a74020406e08f749b", size = 206664, upload-time = "2025-09-15T09:19:40.096Z" },
- { url = "https://files.pythonhosted.org/packages/ed/42/5823ec2b1469395a160b4bf5f14326b4a098f3b6898fbd327366789fa5d3/jiter-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:29fff31190ab3a26de026da2f187814f4b9c6695361e20a9ac2123e4d4378a4c", size = 203520, upload-time = "2025-09-15T09:19:41.798Z" },
- { url = "https://files.pythonhosted.org/packages/97/c4/d530e514d0f4f29b2b68145e7b389cbc7cac7f9c8c23df43b04d3d10fa3e/jiter-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4441a91b80a80249f9a6452c14b2c24708f139f64de959943dfeaa6cb915e8eb", size = 305021, upload-time = "2025-09-15T09:19:43.523Z" },
- { url = "https://files.pythonhosted.org/packages/7a/77/796a19c567c5734cbfc736a6f987affc0d5f240af8e12063c0fb93990ffa/jiter-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff85fc6d2a431251ad82dbd1ea953affb5a60376b62e7d6809c5cd058bb39471", size = 314384, upload-time = "2025-09-15T09:19:44.849Z" },
- { url = "https://files.pythonhosted.org/packages/14/9c/824334de0b037b91b6f3fa9fe5a191c83977c7ec4abe17795d3cb6d174cf/jiter-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5e86126d64706fd28dfc46f910d496923c6f95b395138c02d0e252947f452bd", size = 337389, upload-time = "2025-09-15T09:19:46.094Z" },
- { url = "https://files.pythonhosted.org/packages/a2/95/ed4feab69e6cf9b2176ea29d4ef9d01a01db210a3a2c8a31a44ecdc68c38/jiter-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad8bd82165961867a10f52010590ce0b7a8c53da5ddd8bbb62fef68c181b921", size = 360519, upload-time = "2025-09-15T09:19:47.494Z" },
- { url = "https://files.pythonhosted.org/packages/b5/0c/2ad00f38d3e583caba3909d95b7da1c3a7cd82c0aa81ff4317a8016fb581/jiter-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b42c2cd74273455ce439fd9528db0c6e84b5623cb74572305bdd9f2f2961d3df", size = 487198, upload-time = "2025-09-15T09:19:49.116Z" },
- { url = "https://files.pythonhosted.org/packages/ea/8b/919b64cf3499b79bdfba6036da7b0cac5d62d5c75a28fb45bad7819e22f0/jiter-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0062dab98172dd0599fcdbf90214d0dcde070b1ff38a00cc1b90e111f071982", size = 377835, upload-time = "2025-09-15T09:19:50.468Z" },
- { url = "https://files.pythonhosted.org/packages/29/7f/8ebe15b6e0a8026b0d286c083b553779b4dd63db35b43a3f171b544de91d/jiter-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb948402821bc76d1f6ef0f9e19b816f9b09f8577844ba7140f0b6afe994bc64", size = 347655, upload-time = "2025-09-15T09:19:51.726Z" },
- { url = "https://files.pythonhosted.org/packages/8e/64/332127cef7e94ac75719dda07b9a472af6158ba819088d87f17f3226a769/jiter-0.11.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25a5b1110cca7329fd0daf5060faa1234be5c11e988948e4f1a1923b6a457fe1", size = 386135, upload-time = "2025-09-15T09:19:53.075Z" },
- { url = "https://files.pythonhosted.org/packages/20/c8/557b63527442f84c14774159948262a9d4fabb0d61166f11568f22fc60d2/jiter-0.11.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:bf11807e802a214daf6c485037778843fadd3e2ec29377ae17e0706ec1a25758", size = 516063, upload-time = "2025-09-15T09:19:54.447Z" },
- { url = "https://files.pythonhosted.org/packages/86/13/4164c819df4a43cdc8047f9a42880f0ceef5afeb22e8b9675c0528ebdccd/jiter-0.11.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:dbb57da40631c267861dd0090461222060960012d70fd6e4c799b0f62d0ba166", size = 508139, upload-time = "2025-09-15T09:19:55.764Z" },
- { url = "https://files.pythonhosted.org/packages/fa/70/6e06929b401b331d41ddb4afb9f91cd1168218e3371972f0afa51c9f3c31/jiter-0.11.0-cp313-cp313-win32.whl", hash = "sha256:8e36924dad32c48d3c5e188d169e71dc6e84d6cb8dedefea089de5739d1d2f80", size = 206369, upload-time = "2025-09-15T09:19:57.048Z" },
- { url = "https://files.pythonhosted.org/packages/f4/0d/8185b8e15de6dce24f6afae63380e16377dd75686d56007baa4f29723ea1/jiter-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:452d13e4fd59698408087235259cebe67d9d49173b4dacb3e8d35ce4acf385d6", size = 202538, upload-time = "2025-09-15T09:19:58.35Z" },
- { url = "https://files.pythonhosted.org/packages/13/3a/d61707803260d59520721fa326babfae25e9573a88d8b7b9cb54c5423a59/jiter-0.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:089f9df9f69532d1339e83142438668f52c97cd22ee2d1195551c2b1a9e6cf33", size = 313737, upload-time = "2025-09-15T09:19:59.638Z" },
- { url = "https://files.pythonhosted.org/packages/cd/cc/c9f0eec5d00f2a1da89f6bdfac12b8afdf8d5ad974184863c75060026457/jiter-0.11.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29ed1fe69a8c69bf0f2a962d8d706c7b89b50f1332cd6b9fbda014f60bd03a03", size = 346183, upload-time = "2025-09-15T09:20:01.442Z" },
- { url = "https://files.pythonhosted.org/packages/a6/87/fc632776344e7aabbab05a95a0075476f418c5d29ab0f2eec672b7a1f0ac/jiter-0.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a4d71d7ea6ea8786291423fe209acf6f8d398a0759d03e7f24094acb8ab686ba", size = 204225, upload-time = "2025-09-15T09:20:03.102Z" },
- { url = "https://files.pythonhosted.org/packages/ee/3b/e7f45be7d3969bdf2e3cd4b816a7a1d272507cd0edd2d6dc4b07514f2d9a/jiter-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9a6dff27eca70930bdbe4cbb7c1a4ba8526e13b63dc808c0670083d2d51a4a72", size = 304414, upload-time = "2025-09-15T09:20:04.357Z" },
- { url = "https://files.pythonhosted.org/packages/06/32/13e8e0d152631fcc1907ceb4943711471be70496d14888ec6e92034e2caf/jiter-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ae2a7593a62132c7d4c2abbee80bbbb94fdc6d157e2c6cc966250c564ef774", size = 314223, upload-time = "2025-09-15T09:20:05.631Z" },
- { url = "https://files.pythonhosted.org/packages/0c/7e/abedd5b5a20ca083f778d96bba0d2366567fcecb0e6e34ff42640d5d7a18/jiter-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b13a431dba4b059e9e43019d3022346d009baf5066c24dcdea321a303cde9f0", size = 337306, upload-time = "2025-09-15T09:20:06.917Z" },
- { url = "https://files.pythonhosted.org/packages/ac/e2/30d59bdc1204c86aa975ec72c48c482fee6633120ee9c3ab755e4dfefea8/jiter-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:af62e84ca3889604ebb645df3b0a3f3bcf6b92babbff642bd214616f57abb93a", size = 360565, upload-time = "2025-09-15T09:20:08.283Z" },
- { url = "https://files.pythonhosted.org/packages/fe/88/567288e0d2ed9fa8f7a3b425fdaf2cb82b998633c24fe0d98f5417321aa8/jiter-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6f3b32bb723246e6b351aecace52aba78adb8eeb4b2391630322dc30ff6c773", size = 486465, upload-time = "2025-09-15T09:20:09.613Z" },
- { url = "https://files.pythonhosted.org/packages/18/6e/7b72d09273214cadd15970e91dd5ed9634bee605176107db21e1e4205eb1/jiter-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:adcab442f4a099a358a7f562eaa54ed6456fb866e922c6545a717be51dbed7d7", size = 377581, upload-time = "2025-09-15T09:20:10.884Z" },
- { url = "https://files.pythonhosted.org/packages/58/52/4db456319f9d14deed325f70102577492e9d7e87cf7097bda9769a1fcacb/jiter-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9967c2ab338ee2b2c0102fd379ec2693c496abf71ffd47e4d791d1f593b68e2", size = 347102, upload-time = "2025-09-15T09:20:12.175Z" },
- { url = "https://files.pythonhosted.org/packages/ce/b4/433d5703c38b26083aec7a733eb5be96f9c6085d0e270a87ca6482cbf049/jiter-0.11.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e7d0bed3b187af8b47a981d9742ddfc1d9b252a7235471ad6078e7e4e5fe75c2", size = 386477, upload-time = "2025-09-15T09:20:13.428Z" },
- { url = "https://files.pythonhosted.org/packages/c8/7a/a60bfd9c55b55b07c5c441c5085f06420b6d493ce9db28d069cc5b45d9f3/jiter-0.11.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:f6fe0283e903ebc55f1a6cc569b8c1f3bf4abd026fed85e3ff8598a9e6f982f0", size = 516004, upload-time = "2025-09-15T09:20:14.848Z" },
- { url = "https://files.pythonhosted.org/packages/2e/46/f8363e5ecc179b4ed0ca6cb0a6d3bfc266078578c71ff30642ea2ce2f203/jiter-0.11.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:4ee5821e3d66606b29ae5b497230b304f1376f38137d69e35f8d2bd5f310ff73", size = 507855, upload-time = "2025-09-15T09:20:16.176Z" },
- { url = "https://files.pythonhosted.org/packages/90/33/396083357d51d7ff0f9805852c288af47480d30dd31d8abc74909b020761/jiter-0.11.0-cp314-cp314-win32.whl", hash = "sha256:c2d13ba7567ca8799f17c76ed56b1d49be30df996eb7fa33e46b62800562a5e2", size = 205802, upload-time = "2025-09-15T09:20:17.661Z" },
- { url = "https://files.pythonhosted.org/packages/e7/ab/eb06ca556b2551d41de7d03bf2ee24285fa3d0c58c5f8d95c64c9c3281b1/jiter-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb4790497369d134a07fc763cc88888c46f734abdd66f9fdf7865038bf3a8f40", size = 313405, upload-time = "2025-09-15T09:20:18.918Z" },
- { url = "https://files.pythonhosted.org/packages/af/22/7ab7b4ec3a1c1f03aef376af11d23b05abcca3fb31fbca1e7557053b1ba2/jiter-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e2bbf24f16ba5ad4441a9845e40e4ea0cb9eed00e76ba94050664ef53ef4406", size = 347102, upload-time = "2025-09-15T09:20:20.16Z" },
-]
-
-[[package]]
-name = "jsonschema"
-version = "4.25.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "jsonschema-specifications" },
- { name = "referencing" },
- { name = "rpds-py" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
-]
-
-[[package]]
-name = "jsonschema-specifications"
-version = "2025.9.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "referencing" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
-]
-
-[[package]]
-name = "kosong"
-version = "0.35.0"
-source = { editable = "." }
-dependencies = [
- { name = "anthropic" },
- { name = "google-genai" },
- { name = "jsonschema" },
- { name = "loguru" },
- { name = "mcp" },
- { name = "openai" },
- { name = "pydantic" },
- { name = "python-dotenv" },
- { name = "typing-extensions" },
-]
-
-[package.optional-dependencies]
-contrib = [
- { name = "anthropic" },
- { name = "google-genai" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "inline-snapshot", extra = ["black"] },
- { name = "pdoc" },
- { name = "pyright" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "respx" },
- { name = "ruff" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "anthropic", specifier = ">=0.75.0" },
- { name = "anthropic", marker = "extra == 'contrib'", specifier = ">=0.75.0" },
- { name = "google-genai", specifier = ">=1.56.0" },
- { name = "google-genai", marker = "extra == 'contrib'", specifier = ">=1.55.0" },
- { name = "jsonschema", specifier = ">=4.25.1" },
- { name = "loguru", specifier = ">=0.7.3" },
- { name = "mcp", specifier = ">=1,<2" },
- { name = "openai", specifier = ">=2.14.0,<2.15.0" },
- { name = "pydantic", specifier = ">=2.12.5" },
- { name = "python-dotenv", specifier = ">=1.2.1" },
- { name = "typing-extensions", specifier = ">=4.15.0" },
-]
-provides-extras = ["contrib"]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "inline-snapshot", extras = ["black"], specifier = ">=0.31.1" },
- { name = "pdoc", specifier = ">=16.0.0" },
- { name = "pyright", specifier = ">=1.1.407" },
- { name = "pytest", specifier = ">=9.0.2" },
- { name = "pytest-asyncio", specifier = ">=1.3.0" },
- { name = "respx", specifier = ">=0.22.0" },
- { name = "ruff", specifier = ">=0.14.10" },
-]
-
-[[package]]
-name = "loguru"
-version = "0.7.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "win32-setctime", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
-]
-
-[[package]]
-name = "markdown-it-py"
-version = "4.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "mdurl" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
-]
-
-[[package]]
-name = "markdown2"
-version = "2.5.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/42/f8/b2ae8bf5f28f9b510ae097415e6e4cb63226bb28d7ee01aec03a755ba03b/markdown2-2.5.4.tar.gz", hash = "sha256:a09873f0b3c23dbfae589b0080587df52ad75bb09a5fa6559147554736676889", size = 145652, upload-time = "2025-07-27T16:16:24.307Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/06/2697b5043c3ecb720ce0d243fc7cf5024c0b5b1e450506e9b21939019963/markdown2-2.5.4-py3-none-any.whl", hash = "sha256:3c4b2934e677be7fec0e6f2de4410e116681f4ad50ec8e5ba7557be506d3f439", size = 49954, upload-time = "2025-07-27T16:16:23.026Z" },
-]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
- { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
- { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
- { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
- { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
- { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
- { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
- { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
- { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
- { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
- { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
- { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
- { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
- { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
- { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
- { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
- { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
- { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
- { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
- { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
- { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
- { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
- { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
- { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
- { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
- { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
- { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
-]
-
-[[package]]
-name = "mcp"
-version = "1.25.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "httpx" },
- { name = "httpx-sse" },
- { name = "jsonschema" },
- { name = "pydantic" },
- { name = "pydantic-settings" },
- { name = "pyjwt", extra = ["crypto"] },
- { name = "python-multipart" },
- { name = "pywin32", marker = "sys_platform == 'win32'" },
- { name = "sse-starlette" },
- { name = "starlette" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
- { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" },
-]
-
-[[package]]
-name = "mdurl"
-version = "0.1.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
-]
-
-[[package]]
-name = "mypy-extensions"
-version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
-]
-
-[[package]]
-name = "nodeenv"
-version = "1.9.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
-]
-
-[[package]]
-name = "openai"
-version = "2.14.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "httpx" },
- { name = "jiter" },
- { name = "pydantic" },
- { name = "sniffio" },
- { name = "tqdm" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938, upload-time = "2025-12-19T03:28:45.742Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "25.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
-]
-
-[[package]]
-name = "pathspec"
-version = "0.12.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
-]
-
-[[package]]
-name = "pdoc"
-version = "16.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jinja2" },
- { name = "markdown2" },
- { name = "markupsafe" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ac/fe/ab3f34a5fb08c6b698439a2c2643caf8fef0d61a86dd3fdcd5501c670ab8/pdoc-16.0.0.tar.gz", hash = "sha256:fdadc40cc717ec53919e3cd720390d4e3bcd40405cb51c4918c119447f913514", size = 111890, upload-time = "2025-10-27T16:02:16.345Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/16/a1/56a17b7f9e18c2bb8df73f3833345d97083b344708b97bab148fdd7e0b82/pdoc-16.0.0-py3-none-any.whl", hash = "sha256:070b51de2743b9b1a4e0ab193a06c9e6c12cf4151cf9137656eebb16e8556628", size = 100014, upload-time = "2025-10-27T16:02:15.007Z" },
-]
-
-[[package]]
-name = "platformdirs"
-version = "4.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "pyasn1"
-version = "0.6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
-]
-
-[[package]]
-name = "pyasn1-modules"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyasn1" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
-]
-
-[[package]]
-name = "pycparser"
-version = "2.23"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
-]
-
-[[package]]
-name = "pydantic"
-version = "2.12.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.41.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
- { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
- { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
- { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
- { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
- { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
- { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
- { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
- { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
- { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
- { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
- { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
- { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
- { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
- { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
- { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
- { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
- { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
- { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
- { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
- { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
- { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
- { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
- { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
- { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
- { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
- { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
- { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
- { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
- { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
- { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
- { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
- { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
- { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
- { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
- { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
- { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
- { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
- { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
- { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
- { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
- { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
- { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
- { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
- { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
- { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
- { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
- { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
- { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
- { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
- { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
- { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
- { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
-]
-
-[[package]]
-name = "pydantic-settings"
-version = "2.12.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pydantic" },
- { name = "python-dotenv" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.19.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
-]
-
-[[package]]
-name = "pyjwt"
-version = "2.10.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
-]
-
-[package.optional-dependencies]
-crypto = [
- { name = "cryptography" },
-]
-
-[[package]]
-name = "pyright"
-version = "1.1.407"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "nodeenv" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "9.0.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
-]
-
-[[package]]
-name = "pytest-asyncio"
-version = "1.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pytest" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
-]
-
-[[package]]
-name = "python-dotenv"
-version = "1.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
-]
-
-[[package]]
-name = "python-multipart"
-version = "0.0.21"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
-]
-
-[[package]]
-name = "pytokens"
-version = "0.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d4/c2/dbadcdddb412a267585459142bfd7cc241e6276db69339353ae6e241ab2b/pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43", size = 15368, upload-time = "2025-10-15T08:02:42.738Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/89/5a/c269ea6b348b6f2c32686635df89f32dbe05df1088dd4579302a6f8f99af/pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8", size = 12038, upload-time = "2025-10-15T08:02:41.694Z" },
-]
-
-[[package]]
-name = "pywin32"
-version = "311"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
- { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
- { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
- { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
- { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
- { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
- { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
- { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
-]
-
-[[package]]
-name = "referencing"
-version = "0.36.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "rpds-py" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
-]
-
-[[package]]
-name = "requests"
-version = "2.32.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
-]
-
-[[package]]
-name = "respx"
-version = "0.22.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
-]
-
-[[package]]
-name = "rich"
-version = "14.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markdown-it-py" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
-]
-
-[[package]]
-name = "rpds-py"
-version = "0.27.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" },
- { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" },
- { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" },
- { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" },
- { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" },
- { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" },
- { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" },
- { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" },
- { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" },
- { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" },
- { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" },
- { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" },
- { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" },
- { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" },
- { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" },
- { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" },
- { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" },
- { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" },
- { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" },
- { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" },
- { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" },
- { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" },
- { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" },
- { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" },
- { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" },
- { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" },
- { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" },
- { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" },
- { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" },
- { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" },
- { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" },
- { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" },
- { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" },
- { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" },
- { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" },
- { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" },
- { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" },
- { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" },
- { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" },
- { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" },
- { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" },
- { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" },
- { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" },
- { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" },
- { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" },
- { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" },
- { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" },
- { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" },
- { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" },
- { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" },
- { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" },
- { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" },
- { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" },
- { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" },
- { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" },
- { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" },
- { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" },
- { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" },
- { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" },
- { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" },
- { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" },
- { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" },
- { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" },
- { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" },
- { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" },
- { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" },
- { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" },
- { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" },
- { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" },
- { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" },
-]
-
-[[package]]
-name = "rsa"
-version = "4.9.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyasn1" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
-]
-
-[[package]]
-name = "ruff"
-version = "0.14.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
- { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
- { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
- { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
- { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
- { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
- { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
- { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
- { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
- { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
- { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
- { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
- { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
- { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
- { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
- { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
- { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
- { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
-[[package]]
-name = "sse-starlette"
-version = "3.0.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "starlette" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/17/8b/54651ad49bce99a50fd61a7f19c2b6a79fbb072e693101fbb1194c362054/sse_starlette-3.0.4.tar.gz", hash = "sha256:5e34286862e96ead0eb70f5ddd0bd21ab1f6473a8f44419dd267f431611383dd", size = 22576, upload-time = "2025-12-14T16:22:52.493Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/71/22/8ab1066358601163e1ac732837adba3672f703818f693e179b24e0d3b65c/sse_starlette-3.0.4-py3-none-any.whl", hash = "sha256:32c80ef0d04506ced4b0b6ab8fe300925edc37d26f666afb1874c754895f5dc3", size = 11764, upload-time = "2025-12-14T16:22:51.453Z" },
-]
-
-[[package]]
-name = "starlette"
-version = "0.50.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
-]
-
-[[package]]
-name = "tenacity"
-version = "9.1.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
-]
-
-[[package]]
-name = "tqdm"
-version = "4.67.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
-]
-
-[[package]]
-name = "typing-inspection"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
-]
-
-[[package]]
-name = "uvicorn"
-version = "0.40.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
-]
-
-[[package]]
-name = "websockets"
-version = "15.0.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
- { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
- { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
- { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
- { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
- { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
- { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
- { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
- { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
- { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
- { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
- { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
- { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
- { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
- { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
- { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
- { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
- { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
- { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
- { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
-]
-
-[[package]]
-name = "win32-setctime"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
-]