diff --git a/Claude_Code/.dockerignore b/Claude_Code/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..782a77978a1decef53782774957484208863997c
--- /dev/null
+++ b/Claude_Code/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.venv
+__pycache__
+*.pyc
+.env
+uv.lock
+nvidia_nim_models.json
+pic.png
\ No newline at end of file
diff --git a/Claude_Code/.gitattributes b/Claude_Code/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b
--- /dev/null
+++ b/Claude_Code/.gitattributes
@@ -0,0 +1,35 @@
+*.7z filter=lfs diff=lfs merge=lfs -text
+*.arrow filter=lfs diff=lfs merge=lfs -text
+*.bin filter=lfs diff=lfs merge=lfs -text
+*.bz2 filter=lfs diff=lfs merge=lfs -text
+*.ckpt filter=lfs diff=lfs merge=lfs -text
+*.ftz filter=lfs diff=lfs merge=lfs -text
+*.gz filter=lfs diff=lfs merge=lfs -text
+*.h5 filter=lfs diff=lfs merge=lfs -text
+*.joblib filter=lfs diff=lfs merge=lfs -text
+*.lfs.* filter=lfs diff=lfs merge=lfs -text
+*.mlmodel filter=lfs diff=lfs merge=lfs -text
+*.model filter=lfs diff=lfs merge=lfs -text
+*.msgpack filter=lfs diff=lfs merge=lfs -text
+*.npy filter=lfs diff=lfs merge=lfs -text
+*.npz filter=lfs diff=lfs merge=lfs -text
+*.onnx filter=lfs diff=lfs merge=lfs -text
+*.ot filter=lfs diff=lfs merge=lfs -text
+*.parquet filter=lfs diff=lfs merge=lfs -text
+*.pb filter=lfs diff=lfs merge=lfs -text
+*.pickle filter=lfs diff=lfs merge=lfs -text
+*.pkl filter=lfs diff=lfs merge=lfs -text
+*.pt filter=lfs diff=lfs merge=lfs -text
+*.pth filter=lfs diff=lfs merge=lfs -text
+*.rar filter=lfs diff=lfs merge=lfs -text
+*.safetensors filter=lfs diff=lfs merge=lfs -text
+saved_model/**/* filter=lfs diff=lfs merge=lfs -text
+*.tar.* filter=lfs diff=lfs merge=lfs -text
+*.tar filter=lfs diff=lfs merge=lfs -text
+*.tflite filter=lfs diff=lfs merge=lfs -text
+*.tgz filter=lfs diff=lfs merge=lfs -text
+*.wasm filter=lfs diff=lfs merge=lfs -text
+*.xz filter=lfs diff=lfs merge=lfs -text
+*.zip filter=lfs diff=lfs merge=lfs -text
+*.zst filter=lfs diff=lfs merge=lfs -text
+*tfevents* filter=lfs diff=lfs merge=lfs -text
diff --git a/Claude_Code/.gitignore b/Claude_Code/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..198513ca9adad9f80ee68a7a0f72703f97f5bee8
--- /dev/null
+++ b/Claude_Code/.gitignore
@@ -0,0 +1,12 @@
+__pycache__
+.claude
+.cursor
+.pytest_cache
+.ruff_cache
+.serena
+.venv
+agent_workspace
+.env
+server.log
+.coverage
+llama_cache
\ No newline at end of file
diff --git a/Claude_Code/.python-version b/Claude_Code/.python-version
new file mode 100644
index 0000000000000000000000000000000000000000..12566ed7f96243c27071a23e34c5c298d8fa2954
--- /dev/null
+++ b/Claude_Code/.python-version
@@ -0,0 +1 @@
+3.14.0
\ No newline at end of file
diff --git a/Claude_Code/AGENTS.md b/Claude_Code/AGENTS.md
new file mode 100644
index 0000000000000000000000000000000000000000..d43b17c5c16afefe85fb34cf52b9a4d9cc670dbb
--- /dev/null
+++ b/Claude_Code/AGENTS.md
@@ -0,0 +1,52 @@
+# AGENTIC DIRECTIVE
+
+> This file is identical to CLAUDE.md. Keep them in sync.
+
+## CODING ENVIRONMENT
+
+- Install astral uv using "curl -LsSf https://astral.sh/uv/install.sh | sh" if not already installed and if already installed then update it to the latest version
+- Install Python 3.14 using `uv python install 3.14` if not already installed
+- Always use `uv run` to run files instead of the global `python` command.
+- Current uv ruff formatter is set to py314 which has supports multiple exception types without paranthesis (except TypeError, ValueError:)
+- Read `.env.example` for environment variables.
+- All CI checks must pass; failing checks block merge.
+- Add tests for new changes (including edge cases), then run `uv run pytest`.
+- Run checks in this order: `uv run ruff format`, `uv run ruff check`, `uv run ty check`, `uv run pytest`.
+- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue.
+- All 5 checks are enforced in `tests.yml` on push/merge.
+
+## IDENTITY & CONTEXT
+
+- You are an expert Software Architect and Systems Engineer.
+- Goal: Zero-defect, root-cause-oriented engineering for bugs; test-driven engineering for new features. Think carefully; no need to rush.
+- Code: Write the simplest code possible. Keep the codebase minimal and modular.
+
+## ARCHITECTURE PRINCIPLES (see PLAN.md)
+
+- **Shared utilities**: Extract common logic into shared packages (e.g. `providers/common/`). Do not have one provider import from another provider's utils.
+- **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste.
+- **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside.
+- **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`.
+- **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`).
+- **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters.
+- **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code.
+- **No type ignores**: Do not add `# type: ignore` or `# ty: ignore`. Fix the underlying type issue.
+- **Backward compatibility**: When moving modules, add re-exports from old locations so existing imports keep working.
+
+## COGNITIVE WORKFLOW
+
+1. **ANALYZE**: Read relevant files. Do not guess.
+2. **PLAN**: Map out the logic. Identify root cause or required changes. Order changes by dependency.
+3. **EXECUTE**: Fix the cause, not the symptom. Execute incrementally with clear commits.
+4. **VERIFY**: Run ci checks. Confirm the fix via logs or output.
+5. **SPECIFICITY**: Do exactly as much as asked; nothing more, nothing less.
+6. **PROPAGATION**: Changes impact multiple files; propagate updates correctly.
+
+## SUMMARY STANDARDS
+
+- Summaries must be technical and granular.
+- Include: [Files Changed], [Logic Altered], [Verification Method], [Residual Risks] (if no residual risks then say none).
+
+## TOOLS
+
+- Prefer built-in tools (grep, read_file, etc.) over manual workflows. Check tool availability before use.
diff --git a/Claude_Code/CLAUDE.md b/Claude_Code/CLAUDE.md
new file mode 100644
index 0000000000000000000000000000000000000000..2e14bd454e7cbb7e89e9bd4a99fd37cf8de06665
--- /dev/null
+++ b/Claude_Code/CLAUDE.md
@@ -0,0 +1 @@
+IMPORTANT: Ensure you’ve thoroughly reviewed the [AGENTS.md](AGENTS.md) file before beginning any work.
\ No newline at end of file
diff --git a/Claude_Code/Dockerfile b/Claude_Code/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..502fd33451696ed8a4f6618080c65d4224774fbe
--- /dev/null
+++ b/Claude_Code/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+ENV PYTHONUNBUFFERED=1
+
+# Install uv for faster dependency installation
+RUN pip install --no-cache-dir uv
+
+# Install dependencies
+COPY requirements.txt .
+RUN uv pip install --system --no-cache-dir -r requirements.txt
+
+# Copy application source code
+COPY . .
+
+EXPOSE 7860
+
+CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
diff --git a/Claude_Code/README.md b/Claude_Code/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..46eee19327fef01d83af53395aa143ab2e200b71
--- /dev/null
+++ b/Claude_Code/README.md
@@ -0,0 +1,588 @@
+---
+title: Claude Code
+emoji: 🤖
+colorFrom: indigo
+colorTo: blue
+sdk: docker
+app_port: 7860
+pinned: false
+---
+
+
+
+# 🤖 Free Claude Code
+
+### Use Claude Code CLI & VSCode for free. No Anthropic API key required.
+
+[](https://opensource.org/licenses/MIT)
+[](https://www.python.org/downloads/)
+[](https://github.com/astral-sh/uv)
+[](https://github.com/Alishahryar1/free-claude-code/actions/workflows/tests.yml)
+[](https://pypi.org/project/ty/)
+[](https://github.com/astral-sh/ruff)
+[](https://github.com/Delgan/loguru)
+
+A lightweight proxy that routes Claude Code's Anthropic API calls to **NVIDIA NIM** (40 req/min free), **OpenRouter** (hundreds of models), **LM Studio** (fully local), or **llama.cpp** (local with Anthropic endpoints).
+
+[Quick Start](#quick-start) · [Providers](#providers) · [Discord Bot](#discord-bot) · [Configuration](#configuration) · [Development](#development) · [Contributing](#contributing)
+
+---
+
+
+
+
+

+
Claude Code running via NVIDIA NIM, completely free
+
+
+## Features
+
+| Feature | Description |
+| -------------------------- | ----------------------------------------------------------------------------------------------- |
+| **Zero Cost** | 40 req/min free on NVIDIA NIM. Free models on OpenRouter. Fully local with LM Studio |
+| **Drop-in Replacement** | Set 2 env vars. No modifications to Claude Code CLI or VSCode extension needed |
+| **4 Providers** | NVIDIA NIM, OpenRouter (hundreds of models), LM Studio (local), llama.cpp (`llama-server`) |
+| **Per-Model Mapping** | Route Opus / Sonnet / Haiku to different models and providers. Mix providers freely |
+| **Thinking Token Support** | Parses `` tags and `reasoning_content` into native Claude thinking blocks |
+| **Heuristic Tool Parser** | Models outputting tool calls as text are auto-parsed into structured tool use |
+| **Request Optimization** | 5 categories of trivial API calls intercepted locally, saving quota and latency |
+| **Smart Rate Limiting** | Proactive rolling-window throttle + reactive 429 exponential backoff + optional concurrency cap |
+| **Discord / Telegram Bot** | Remote autonomous coding with tree-based threading, session persistence, and live progress |
+| **Subagent Control** | Task tool interception forces `run_in_background=False`. No runaway subagents |
+| **Extensible** | Clean `BaseProvider` and `MessagingPlatform` ABCs. Add new providers or platforms easily |
+
+## Quick Start
+
+### Prerequisites
+
+1. Get an API key (or use LM Studio / llama.cpp locally):
+ - **NVIDIA NIM**: [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys)
+ - **OpenRouter**: [openrouter.ai/keys](https://openrouter.ai/keys)
+ - **LM Studio**: No API key needed. Run locally with [LM Studio](https://lmstudio.ai)
+ - **llama.cpp**: No API key needed. Run `llama-server` locally.
+2. Install [Claude Code](https://github.com/anthropics/claude-code)
+3. Install [uv](https://github.com/astral-sh/uv) (or `uv self update` if already installed)
+
+### Clone & Configure
+
+```bash
+git clone https://github.com/Alishahryar1/free-claude-code.git
+cd free-claude-code
+cp .env.example .env
+```
+
+Choose your provider and edit `.env`:
+
+
+NVIDIA NIM (40 req/min free, recommended)
+
+```dotenv
+NVIDIA_NIM_API_KEY="nvapi-your-key-here"
+
+MODEL_OPUS="nvidia_nim/z-ai/glm4.7"
+MODEL_SONNET="nvidia_nim/moonshotai/kimi-k2-thinking"
+MODEL_HAIKU="nvidia_nim/stepfun-ai/step-3.5-flash"
+MODEL="nvidia_nim/z-ai/glm4.7" # fallback
+
+# Enable for thinking models (kimi, nemotron). Leave false for others (e.g. Mistral).
+NIM_ENABLE_THINKING=true
+```
+
+
+
+
+OpenRouter (hundreds of models)
+
+```dotenv
+OPENROUTER_API_KEY="sk-or-your-key-here"
+
+MODEL_OPUS="open_router/deepseek/deepseek-r1-0528:free"
+MODEL_SONNET="open_router/openai/gpt-oss-120b:free"
+MODEL_HAIKU="open_router/stepfun/step-3.5-flash:free"
+MODEL="open_router/stepfun/step-3.5-flash:free" # fallback
+```
+
+
+
+
+LM Studio (fully local, no API key)
+
+```dotenv
+MODEL_OPUS="lmstudio/unsloth/MiniMax-M2.5-GGUF"
+MODEL_SONNET="lmstudio/unsloth/Qwen3.5-35B-A3B-GGUF"
+MODEL_HAIKU="lmstudio/unsloth/GLM-4.7-Flash-GGUF"
+MODEL="lmstudio/unsloth/GLM-4.7-Flash-GGUF" # fallback
+```
+
+
+
+
+llama.cpp (fully local, no API key)
+
+```dotenv
+LLAMACPP_BASE_URL="http://localhost:8080/v1"
+
+MODEL_OPUS="llamacpp/local-model"
+MODEL_SONNET="llamacpp/local-model"
+MODEL_HAIKU="llamacpp/local-model"
+MODEL="llamacpp/local-model"
+```
+
+
+
+
+Mix providers
+
+Each `MODEL_*` variable can use a different provider. `MODEL` is the fallback for unrecognized Claude models.
+
+```dotenv
+NVIDIA_NIM_API_KEY="nvapi-your-key-here"
+OPENROUTER_API_KEY="sk-or-your-key-here"
+
+MODEL_OPUS="nvidia_nim/moonshotai/kimi-k2.5"
+MODEL_SONNET="open_router/deepseek/deepseek-r1-0528:free"
+MODEL_HAIKU="lmstudio/unsloth/GLM-4.7-Flash-GGUF"
+MODEL="nvidia_nim/z-ai/glm4.7" # fallback
+```
+
+
+
+
+Optional Authentication (restrict access to your proxy)
+
+Set `ANTHROPIC_AUTH_TOKEN` in `.env` to require clients to authenticate:
+
+```dotenv
+ANTHROPIC_AUTH_TOKEN="your-secret-token-here"
+```
+
+**How it works:**
+- If `ANTHROPIC_AUTH_TOKEN` is empty (default), no authentication is required (backward compatible)
+- If set, clients must provide the same token via the `ANTHROPIC_AUTH_TOKEN` header
+- For private Hugging Face Spaces, query auth is supported as `?psw=token`, `?psw:token`, or `?psw%3Atoken`
+- The `claude-pick` script automatically reads the token from `.env` if configured
+
+**Example usage:**
+```bash
+# With authentication
+ANTHROPIC_AUTH_TOKEN="your-secret-token-here" \
+ANTHROPIC_BASE_URL="http://localhost:8082" claude
+
+# Hugging Face private Space (query auth in URL)
+ANTHROPIC_API_KEY="Jack@188" \
+ANTHROPIC_BASE_URL="https://.hf.space?psw:Jack%40188" claude
+
+# claude-pick automatically uses the configured token
+claude-pick
+```
+
+Note: `HEAD /` returning `405 Method Not Allowed` means auth already passed; only `GET /` is implemented.
+
+Use this feature if:
+- Running the proxy on a public network
+- Sharing the server with others but restricting access
+- Wanting an additional layer of security
+
+
+
+### Run It
+
+**Terminal 1:** Start the proxy server:
+
+```bash
+uv run uvicorn server:app --host 0.0.0.0 --port 8082
+```
+
+**Terminal 2:** Run Claude Code:
+
+#### Powershell
+```powershell
+$env:ANTHROPIC_BASE_URL="http://localhost:8082?psw:Jack%40188"; $env:ANTHROPIC_API_KEY="Jack@188"; claude
+```
+#### Bash
+```bash
+export ANTHROPIC_BASE_URL="http://localhost:8082?psw:Jack%40188"; export ANTHROPIC_API_KEY="Jack@188"; claude
+```
+
+That's it! Claude Code now uses your configured provider for free.
+
+### One-Click Factory Reset (Space Admin)
+
+Open the admin page:
+
+- Local: `http://localhost:8082/admin/factory-reset?psw:Jack%40188`
+- Space: `https://.hf.space/admin/factory-reset?psw:Jack%40188`
+
+Click **Factory Restart** to clear runtime cache + workspace data and restart the server.
+
+
+VSCode Extension Setup
+
+1. Start the proxy server (same as above).
+2. Open Settings (`Ctrl + ,`) and search for `claude-code.environmentVariables`.
+3. Click **Edit in settings.json** and add:
+
+```json
+"claudeCode.environmentVariables": [
+ { "name": "ANTHROPIC_BASE_URL", "value": "http://localhost:8082" },
+ { "name": "ANTHROPIC_AUTH_TOKEN", "value": "freecc" }
+]
+```
+
+4. Reload extensions.
+5. **If you see the login screen**: Click **Anthropic Console**, then authorize. The extension will start working. You may be redirected to buy credits in the browser; ignore it — the extension already works.
+
+To switch back to Anthropic models, comment out the added block and reload extensions.
+
+
+
+
+Multi-Model Support (Model Picker)
+
+`claude-pick` is an interactive model selector that lets you choose any model from your active provider each time you launch Claude, without editing `MODEL` in `.env`.
+
+https://github.com/user-attachments/assets/9a33c316-90f8-4418-9650-97e7d33ad645
+
+**1. Install [fzf](https://github.com/junegunn/fzf)**:
+
+```bash
+brew install fzf # macOS/Linux
+```
+
+**2. Add the alias to `~/.zshrc` or `~/.bashrc`:**
+
+```bash
+alias claude-pick="/absolute/path/to/free-claude-code/claude-pick"
+```
+
+Then reload your shell (`source ~/.zshrc` or `source ~/.bashrc`) and run `claude-pick`.
+
+**Or use a fixed model alias** (no picker needed):
+
+```bash
+alias claude-kimi='ANTHROPIC_BASE_URL="http://localhost:8082" ANTHROPIC_AUTH_TOKEN="freecc:moonshotai/kimi-k2.5" claude'
+```
+
+
+
+### Install as a Package (no clone needed)
+
+```bash
+uv tool install git+https://github.com/Alishahryar1/free-claude-code.git
+fcc-init # creates ~/.config/free-claude-code/.env from the built-in template
+```
+
+Edit `~/.config/free-claude-code/.env` with your API keys and model names, then:
+
+```bash
+free-claude-code # starts the server
+```
+
+> To update: `uv tool upgrade free-claude-code`
+
+---
+
+## How It Works
+
+```
+┌─────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
+│ Claude Code │───────>│ Free Claude Code │───────>│ LLM Provider │
+│ CLI / VSCode │<───────│ Proxy (:8082) │<───────│ NIM / OR / LMS │
+└─────────────────┘ └──────────────────────┘ └──────────────────┘
+ Anthropic API OpenAI-compatible
+ format (SSE) format (SSE)
+```
+
+- **Transparent proxy**: Claude Code sends standard Anthropic API requests; the proxy forwards them to your configured provider
+- **Per-model routing**: Opus / Sonnet / Haiku requests resolve to their model-specific backend, with `MODEL` as fallback
+- **Request optimization**: 5 categories of trivial requests (quota probes, title generation, prefix detection, suggestions, filepath extraction) are intercepted and responded to locally without using API quota
+- **Format translation**: Requests are translated from Anthropic format to the provider's OpenAI-compatible format and streamed back
+- **Thinking tokens**: `` tags and `reasoning_content` fields are converted into native Claude thinking blocks
+
+---
+
+## Providers
+
+| Provider | Cost | Rate Limit | Best For |
+| -------------- | ------------ | ---------- | ------------------------------------ |
+| **NVIDIA NIM** | Free | 40 req/min | Daily driver, generous free tier |
+| **OpenRouter** | Free / Paid | Varies | Model variety, fallback options |
+| **LM Studio** | Free (local) | Unlimited | Privacy, offline use, no rate limits |
+| **llama.cpp** | Free (local) | Unlimited | Lightweight local inference engine |
+
+Models use a prefix format: `provider_prefix/model/name`. An invalid prefix causes an error.
+
+| Provider | `MODEL` prefix | API Key Variable | Default Base URL |
+| ---------- | ----------------- | -------------------- | ----------------------------- |
+| NVIDIA NIM | `nvidia_nim/...` | `NVIDIA_NIM_API_KEY` | `integrate.api.nvidia.com/v1` |
+| OpenRouter | `open_router/...` | `OPENROUTER_API_KEY` | `openrouter.ai/api/v1` |
+| LM Studio | `lmstudio/...` | (none) | `localhost:1234/v1` |
+| llama.cpp | `llamacpp/...` | (none) | `localhost:8080/v1` |
+
+
+NVIDIA NIM models
+
+Popular models (full list in [`nvidia_nim_models.json`](nvidia_nim_models.json)):
+
+- `nvidia_nim/minimaxai/minimax-m2.5`
+- `nvidia_nim/qwen/qwen3.5-397b-a17b`
+- `nvidia_nim/z-ai/glm5`
+- `nvidia_nim/moonshotai/kimi-k2.5`
+- `nvidia_nim/stepfun-ai/step-3.5-flash`
+
+Browse: [build.nvidia.com](https://build.nvidia.com/explore/discover) · Update list: `curl "https://integrate.api.nvidia.com/v1/models" > nvidia_nim_models.json`
+
+
+
+
+OpenRouter models
+
+Popular free models:
+
+- `open_router/arcee-ai/trinity-large-preview:free`
+- `open_router/stepfun/step-3.5-flash:free`
+- `open_router/deepseek/deepseek-r1-0528:free`
+- `open_router/openai/gpt-oss-120b:free`
+
+Browse: [openrouter.ai/models](https://openrouter.ai/models) · [Free models](https://openrouter.ai/collections/free-models)
+
+
+
+
+LM Studio models
+
+Run models locally with [LM Studio](https://lmstudio.ai). Load a model in the Chat or Developer tab, then set `MODEL` to its identifier.
+
+Examples with native tool-use support:
+
+- `LiquidAI/LFM2-24B-A2B-GGUF`
+- `unsloth/MiniMax-M2.5-GGUF`
+- `unsloth/GLM-4.7-Flash-GGUF`
+- `unsloth/Qwen3.5-35B-A3B-GGUF`
+
+Browse: [model.lmstudio.ai](https://model.lmstudio.ai)
+
+
+
+
+llama.cpp models
+
+Run models locally using `llama-server`. Ensure you have a tool-capable GGUF. Set `MODEL` to whatever arbitrary name you'd like (e.g. `llamacpp/my-model`), as `llama-server` ignores the model name when run via `/v1/messages`.
+
+See the Unsloth docs for detailed instructions and capable models:
+[https://unsloth.ai/docs/models/qwen3.5#qwen3.5-small-0.8b-2b-4b-9b](https://unsloth.ai/docs/models/qwen3.5#qwen3.5-small-0.8b-2b-4b-9b)
+
+
+
+---
+
+## Discord Bot
+
+Control Claude Code remotely from Discord (or Telegram). Send tasks, watch live progress, and manage multiple concurrent sessions.
+
+**Capabilities:**
+
+- Tree-based message threading: reply to a message to fork the conversation
+- Session persistence across server restarts
+- Live streaming of thinking tokens, tool calls, and results
+- Unlimited concurrent Claude CLI sessions (concurrency controlled by `PROVIDER_MAX_CONCURRENCY`)
+- Voice notes: send voice messages; they are transcribed and processed as regular prompts
+- Commands: `/stop` (cancel a task; reply to a message to stop only that task), `/clear` (reset all sessions, or reply to clear a branch), `/stats`
+
+### Setup
+
+1. **Create a Discord Bot**: Go to [Discord Developer Portal](https://discord.com/developers/applications), create an application, add a bot, and copy the token. Enable **Message Content Intent** under Bot settings.
+
+2. **Edit `.env`:**
+
+```dotenv
+MESSAGING_PLATFORM="discord"
+DISCORD_BOT_TOKEN="your_discord_bot_token"
+ALLOWED_DISCORD_CHANNELS="123456789,987654321"
+```
+
+> Enable Developer Mode in Discord (Settings → Advanced), then right-click a channel and "Copy ID". Comma-separate multiple channels. If empty, no channels are allowed.
+
+3. **Configure the workspace** (where Claude will operate):
+
+```dotenv
+CLAUDE_WORKSPACE="./agent_workspace"
+ALLOWED_DIR="C:/Users/yourname/projects"
+```
+
+4. **Start the server:**
+
+```bash
+uv run uvicorn server:app --host 0.0.0.0 --port 8082
+```
+
+5. **Invite the bot** via OAuth2 URL Generator (scopes: `bot`, permissions: Read Messages, Send Messages, Manage Messages, Read Message History).
+
+### Telegram
+
+Set `MESSAGING_PLATFORM=telegram` and configure:
+
+```dotenv
+TELEGRAM_BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
+ALLOWED_TELEGRAM_USER_ID="your_telegram_user_id"
+```
+
+Get a token from [@BotFather](https://t.me/BotFather); find your user ID via [@userinfobot](https://t.me/userinfobot).
+
+### Voice Notes
+
+Send voice messages on Discord or Telegram; they are transcribed and processed as regular prompts.
+
+| Backend | Description | API Key |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------- |
+| **Local Whisper** (default) | [Hugging Face Whisper](https://huggingface.co/openai/whisper-large-v3-turbo) — free, offline, CUDA compatible | not required |
+| **NVIDIA NIM** | Whisper/Parakeet models via gRPC | `NVIDIA_NIM_API_KEY` |
+
+**Install the voice extras:**
+
+```bash
+# If you cloned the repo:
+uv sync --extra voice_local # Local Whisper
+uv sync --extra voice # NVIDIA NIM
+uv sync --extra voice --extra voice_local # Both
+
+# If you installed as a package (no clone):
+uv tool install "free-claude-code[voice_local] @ git+https://github.com/Alishahryar1/free-claude-code.git"
+uv tool install "free-claude-code[voice] @ git+https://github.com/Alishahryar1/free-claude-code.git"
+uv tool install "free-claude-code[voice,voice_local] @ git+https://github.com/Alishahryar1/free-claude-code.git"
+```
+
+Configure via `WHISPER_DEVICE` (`cpu` | `cuda` | `nvidia_nim`) and `WHISPER_MODEL`. See the [Configuration](#configuration) table for all voice variables and supported model values.
+
+---
+
+## Configuration
+
+### Core
+
+| Variable | Description | Default |
+| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------- |
+| `MODEL` | Fallback model (`provider/model/name` format; invalid prefix → error) | `nvidia_nim/stepfun-ai/step-3.5-flash` |
+| `MODEL_OPUS` | Model for Claude Opus requests (falls back to `MODEL`) | `nvidia_nim/z-ai/glm4.7` |
+| `MODEL_SONNET` | Model for Claude Sonnet requests (falls back to `MODEL`) | `open_router/arcee-ai/trinity-large-preview:free` |
+| `MODEL_HAIKU` | Model for Claude Haiku requests (falls back to `MODEL`) | `open_router/stepfun/step-3.5-flash:free` |
+| `NVIDIA_NIM_API_KEY` | NVIDIA API key | required for NIM |
+| `NIM_ENABLE_THINKING` | Send `chat_template_kwargs` + `reasoning_budget` on NIM requests. Enable for thinking models (kimi, nemotron); leave `false` for others (e.g. Mistral) | `false` |
+| `OPENROUTER_API_KEY` | OpenRouter API key | required for OpenRouter |
+| `LM_STUDIO_BASE_URL` | LM Studio server URL | `http://localhost:1234/v1` |
+| `LLAMACPP_BASE_URL` | llama.cpp server URL | `http://localhost:8080/v1` |
+
+### Rate Limiting & Timeouts
+
+| Variable | Description | Default |
+| -------------------------- | ----------------------------------------- | ------- |
+| `PROVIDER_RATE_LIMIT` | LLM API requests per window | `40` |
+| `PROVIDER_RATE_WINDOW` | Rate limit window (seconds) | `60` |
+| `PROVIDER_MAX_CONCURRENCY` | Max simultaneous open provider streams | `5` |
+| `HTTP_READ_TIMEOUT` | Read timeout for provider requests (s) | `120` |
+| `HTTP_WRITE_TIMEOUT` | Write timeout for provider requests (s) | `10` |
+| `HTTP_CONNECT_TIMEOUT` | Connect timeout for provider requests (s) | `2` |
+
+### Messaging & Voice
+
+| Variable | Description | Default |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
+| `MESSAGING_PLATFORM` | `discord` or `telegram` | `discord` |
+| `DISCORD_BOT_TOKEN` | Discord bot token | `""` |
+| `ALLOWED_DISCORD_CHANNELS` | Comma-separated channel IDs (empty = none allowed) | `""` |
+| `TELEGRAM_BOT_TOKEN` | Telegram bot token | `""` |
+| `ALLOWED_TELEGRAM_USER_ID` | Allowed Telegram user ID | `""` |
+| `CLAUDE_WORKSPACE` | Directory where the agent operates | `./agent_workspace` |
+| `ALLOWED_DIR` | Allowed directories for the agent | `""` |
+| `MESSAGING_RATE_LIMIT` | Messaging messages per window | `1` |
+| `MESSAGING_RATE_WINDOW` | Messaging window (seconds) | `1` |
+| `VOICE_NOTE_ENABLED` | Enable voice note handling | `true` |
+| `WHISPER_DEVICE` | `cpu` \| `cuda` \| `nvidia_nim` | `cpu` |
+| `WHISPER_MODEL` | Whisper model (local: `tiny`/`base`/`small`/`medium`/`large-v2`/`large-v3`/`large-v3-turbo`; NIM: `openai/whisper-large-v3`, `nvidia/parakeet-ctc-1.1b-asr`, etc.) | `base` |
+| `HF_TOKEN` | Hugging Face token for faster downloads (local Whisper, optional) | — |
+
+
+Advanced: Request optimization flags
+
+These are enabled by default and intercept trivial Claude Code requests locally to save API quota.
+
+| Variable | Description | Default |
+| --------------------------------- | ------------------------------ | ------- |
+| `FAST_PREFIX_DETECTION` | Enable fast prefix detection | `true` |
+| `ENABLE_NETWORK_PROBE_MOCK` | Mock network probe requests | `true` |
+| `ENABLE_TITLE_GENERATION_SKIP` | Skip title generation requests | `true` |
+| `ENABLE_SUGGESTION_MODE_SKIP` | Skip suggestion mode requests | `true` |
+| `ENABLE_FILEPATH_EXTRACTION_MOCK` | Mock filepath extraction | `true` |
+
+
+
+See [`.env.example`](.env.example) for all supported parameters.
+
+---
+
+## Development
+
+### Project Structure
+
+```
+free-claude-code/
+├── server.py # Entry point
+├── api/ # FastAPI routes, request detection, optimization handlers
+├── providers/ # BaseProvider, OpenAICompatibleProvider, NIM, OpenRouter, LM Studio, llamacpp
+│ └── common/ # Shared utils (SSE builder, message converter, parsers, error mapping)
+├── messaging/ # MessagingPlatform ABC + Discord/Telegram bots, session management
+├── config/ # Settings, NIM config, logging
+├── cli/ # CLI session and process management
+└── tests/ # Pytest test suite
+```
+
+### Commands
+
+```bash
+uv run ruff format # Format code
+uv run ruff check # Lint
+uv run ty check # Type checking
+uv run pytest # Run tests
+```
+
+### Extending
+
+**Adding an OpenAI-compatible provider** (Groq, Together AI, etc.) — extend `OpenAICompatibleProvider`:
+
+```python
+from providers.openai_compat import OpenAICompatibleProvider
+from providers.base import ProviderConfig
+
+class MyProvider(OpenAICompatibleProvider):
+ def __init__(self, config: ProviderConfig):
+ super().__init__(config, provider_name="MYPROVIDER",
+ base_url="https://api.example.com/v1", api_key=config.api_key)
+```
+
+**Adding a fully custom provider** — extend `BaseProvider` directly and implement `stream_response()`.
+
+**Adding a messaging platform** — extend `MessagingPlatform` in `messaging/` and implement `start()`, `stop()`, `send_message()`, `edit_message()`, and `on_message()`.
+
+---
+
+## Contributing
+
+- Report bugs or suggest features via [Issues](https://github.com/Alishahryar1/free-claude-code/issues)
+- Add new LLM providers (Groq, Together AI, etc.)
+- Add new messaging platforms (Slack, etc.)
+- Improve test coverage
+- Not accepting Docker integration PRs for now
+
+```bash
+git checkout -b my-feature
+uv run ruff format && uv run ruff check && uv run ty check && uv run pytest
+# Open a pull request
+```
+
+---
+
+## License
+
+MIT License. See [LICENSE](LICENSE) for details.
+
+Built with [FastAPI](https://fastapi.tiangolo.com/), [OpenAI Python SDK](https://github.com/openai/openai-python), [discord.py](https://github.com/Rapptz/discord.py), and [python-telegram-bot](https://python-telegram-bot.org/).
diff --git a/Claude_Code/api/__init__.py b/Claude_Code/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..459d0193612c6a6f10f99e73e2c42fcded5d2dd1
--- /dev/null
+++ b/Claude_Code/api/__init__.py
@@ -0,0 +1,21 @@
+"""API layer for Claude Code Proxy."""
+
+from .app import app, create_app
+from .dependencies import get_provider, get_provider_for_type
+from .models import (
+ MessagesRequest,
+ MessagesResponse,
+ TokenCountRequest,
+ TokenCountResponse,
+)
+
+__all__ = [
+ "MessagesRequest",
+ "MessagesResponse",
+ "TokenCountRequest",
+ "TokenCountResponse",
+ "app",
+ "create_app",
+ "get_provider",
+ "get_provider_for_type",
+]
diff --git a/Claude_Code/api/app.py b/Claude_Code/api/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..765e9bde87946f0f0506e5231ef1ddaaf0692f10
--- /dev/null
+++ b/Claude_Code/api/app.py
@@ -0,0 +1,273 @@
+"""FastAPI application factory and configuration."""
+
+import asyncio
+import os
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import JSONResponse
+from loguru import logger
+
+from config.logging_config import configure_logging
+from config.settings import get_settings
+from providers.exceptions import ProviderError
+
+from .dependencies import cleanup_provider, validate_request_api_key
+from .routes import router
+
+# Opt-in to future behavior for python-telegram-bot
+os.environ["PTB_TIMEDELTA"] = "1"
+
+# Configure logging first (before any module logs)
+_settings = get_settings()
+configure_logging(_settings.log_file)
+
+
+_SHUTDOWN_TIMEOUT_S = 5.0
+
+
+def _normalize_malformed_query_base_url_request(request: Request) -> None:
+ """Normalize malformed request targets when base URL contains query auth.
+
+ Some clients concatenate paths onto a base URL containing query params as plain
+ strings, producing targets like:
+ /?psw:token/v1/messages?beta=true
+ This rewrites them to:
+ /v1/messages?psw:token&beta=true
+ """
+ if request.scope.get("path") != "/":
+ return
+
+ raw_query_bytes = request.scope.get("query_string", b"")
+ raw_query = raw_query_bytes.decode("utf-8", errors="ignore")
+ if not raw_query or "/v1/" not in raw_query:
+ return
+
+ auth_part, _, remainder = raw_query.partition("/v1/")
+ if not auth_part or not remainder:
+ return
+
+ if "?" in remainder:
+ path_suffix, trailing_query = remainder.split("?", 1)
+ else:
+ path_suffix, trailing_query = remainder, ""
+
+ new_path = f"/v1/{path_suffix}"
+ new_query = auth_part if not trailing_query else f"{auth_part}&{trailing_query}"
+
+ request.scope["path"] = new_path
+ request.scope["raw_path"] = new_path.encode("utf-8")
+ request.scope["query_string"] = new_query.encode("utf-8")
+
+
+async def _best_effort(
+ name: str, awaitable, timeout_s: float = _SHUTDOWN_TIMEOUT_S
+) -> None:
+ """Run a shutdown step with timeout; never raise to callers."""
+ try:
+ await asyncio.wait_for(awaitable, timeout=timeout_s)
+ except TimeoutError:
+ logger.warning(f"Shutdown step timed out: {name} ({timeout_s}s)")
+ except Exception as e:
+ logger.warning(f"Shutdown step failed: {name}: {type(e).__name__}: {e}")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Application lifespan manager."""
+ settings = get_settings()
+ logger.info("Starting Claude Code Proxy...")
+
+ # Initialize messaging platform if configured
+ messaging_platform = None
+ message_handler = None
+ cli_manager = None
+
+ try:
+ # Use the messaging factory to create the right platform
+ from messaging.platforms.factory import create_messaging_platform
+
+ messaging_platform = create_messaging_platform(
+ platform_type=settings.messaging_platform,
+ bot_token=settings.telegram_bot_token,
+ allowed_user_id=settings.allowed_telegram_user_id,
+ discord_bot_token=settings.discord_bot_token,
+ allowed_discord_channels=settings.allowed_discord_channels,
+ )
+
+ if messaging_platform:
+ from cli.manager import CLISessionManager
+ from messaging.handler import ClaudeMessageHandler
+ from messaging.session import SessionStore
+
+ # Setup workspace - CLI runs in allowed_dir if set (e.g. project root)
+ workspace = (
+ os.path.abspath(settings.allowed_dir)
+ if settings.allowed_dir
+ else os.getcwd()
+ )
+ os.makedirs(workspace, exist_ok=True)
+
+ # Session data stored in agent_workspace
+ data_path = os.path.abspath(settings.claude_workspace)
+ os.makedirs(data_path, exist_ok=True)
+
+ api_url = f"http://{settings.host}:{settings.port}/v1"
+ allowed_dirs = [workspace] if settings.allowed_dir else []
+ plans_dir_abs = os.path.abspath(
+ os.path.join(settings.claude_workspace, "plans")
+ )
+ plans_directory = os.path.relpath(plans_dir_abs, workspace)
+ cli_manager = CLISessionManager(
+ workspace_path=workspace,
+ api_url=api_url,
+ allowed_dirs=allowed_dirs,
+ plans_directory=plans_directory,
+ )
+
+ # Initialize session store
+ session_store = SessionStore(
+ storage_path=os.path.join(data_path, "sessions.json")
+ )
+
+ # Create and register message handler
+ message_handler = ClaudeMessageHandler(
+ platform=messaging_platform,
+ cli_manager=cli_manager,
+ session_store=session_store,
+ )
+
+ # Restore tree state if available
+ saved_trees = session_store.get_all_trees()
+ if saved_trees:
+ logger.info(f"Restoring {len(saved_trees)} conversation trees...")
+ from messaging.trees.queue_manager import TreeQueueManager
+
+ message_handler.replace_tree_queue(
+ TreeQueueManager.from_dict(
+ {
+ "trees": saved_trees,
+ "node_to_tree": session_store.get_node_mapping(),
+ },
+ queue_update_callback=message_handler.update_queue_positions,
+ node_started_callback=message_handler.mark_node_processing,
+ )
+ )
+ # Reconcile restored state - anything PENDING/IN_PROGRESS is lost across restart
+ if message_handler.tree_queue.cleanup_stale_nodes() > 0:
+ # Sync back and save
+ tree_data = message_handler.tree_queue.to_dict()
+ session_store.sync_from_tree_data(
+ tree_data["trees"], tree_data["node_to_tree"]
+ )
+
+ # Wire up the handler
+ messaging_platform.on_message(message_handler.handle_message)
+
+ # Start the platform
+ await messaging_platform.start()
+ logger.info(
+ f"{messaging_platform.name} platform started with message handler"
+ )
+
+ except ImportError as e:
+ logger.warning(f"Messaging module import error: {e}")
+ except Exception as e:
+ logger.error(f"Failed to start messaging platform: {e}")
+ import traceback
+
+ logger.error(traceback.format_exc())
+
+ # Store in app state for access in routes
+ app.state.messaging_platform = messaging_platform
+ app.state.message_handler = message_handler
+ app.state.cli_manager = cli_manager
+
+ yield
+
+ # Cleanup
+ if message_handler and hasattr(message_handler, "session_store"):
+ try:
+ message_handler.session_store.flush_pending_save()
+ except Exception as e:
+ logger.warning(f"Session store flush on shutdown: {e}")
+ logger.info("Shutdown requested, cleaning up...")
+ if messaging_platform:
+ await _best_effort("messaging_platform.stop", messaging_platform.stop())
+ if cli_manager:
+ await _best_effort("cli_manager.stop_all", cli_manager.stop_all())
+ await _best_effort("cleanup_provider", cleanup_provider())
+
+ # Ensure background limiter worker doesn't keep the loop alive.
+ try:
+ from messaging.limiter import MessagingRateLimiter
+
+ await _best_effort(
+ "MessagingRateLimiter.shutdown_instance",
+ MessagingRateLimiter.shutdown_instance(),
+ timeout_s=2.0,
+ )
+ except Exception:
+ # Limiter may never have been imported/initialized.
+ pass
+
+ logger.info("Server shut down cleanly")
+
+
+def create_app() -> FastAPI:
+ """Create and configure the FastAPI application."""
+ app = FastAPI(
+ title="Claude Code Proxy",
+ version="2.0.0",
+ lifespan=lifespan,
+ )
+
+ @app.middleware("http")
+ async def enforce_api_key(request: Request, call_next):
+ """Enforce API key for every request before routing/method matching."""
+ _normalize_malformed_query_base_url_request(request)
+ try:
+ validate_request_api_key(request, get_settings())
+ except HTTPException as exc:
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"detail": exc.detail},
+ )
+ return await call_next(request)
+
+ # Register routes
+ app.include_router(router)
+
+ # Exception handlers
+ @app.exception_handler(ProviderError)
+ async def provider_error_handler(request: Request, exc: ProviderError):
+ """Handle provider-specific errors and return Anthropic format."""
+ logger.error(f"Provider Error: {exc.error_type} - {exc.message}")
+ return JSONResponse(
+ status_code=exc.status_code,
+ content=exc.to_anthropic_format(),
+ )
+
+ @app.exception_handler(Exception)
+ async def general_error_handler(request: Request, exc: Exception):
+ """Handle general errors and return Anthropic format."""
+ logger.error(f"General Error: {exc!s}")
+ import traceback
+
+ logger.error(traceback.format_exc())
+ return JSONResponse(
+ status_code=500,
+ content={
+ "type": "error",
+ "error": {
+ "type": "api_error",
+ "message": "An unexpected error occurred.",
+ },
+ },
+ )
+
+ return app
+
+
+# Default app instance for uvicorn
+app = create_app()
diff --git a/Claude_Code/api/command_utils.py b/Claude_Code/api/command_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea5251bc0ab4287ad6e12e869452fa7bbf1c7551
--- /dev/null
+++ b/Claude_Code/api/command_utils.py
@@ -0,0 +1,139 @@
+"""Command parsing utilities for API optimizations."""
+
+import shlex
+
+
+def extract_command_prefix(command: str) -> str:
+ """Extract the command prefix for fast prefix detection.
+
+ Parses a shell command safely, handling environment variables and
+ command injection attempts. Returns the command prefix suitable
+ for quick identification.
+
+ Returns:
+ Command prefix (e.g., "git", "git commit", "npm install")
+ or "none" if no valid command found
+ """
+ if "`" in command or "$(" in command:
+ return "command_injection_detected"
+
+ try:
+ parts = shlex.split(command, posix=False)
+ if not parts:
+ return "none"
+
+ env_prefix = []
+ cmd_start = 0
+ for i, part in enumerate(parts):
+ if "=" in part and not part.startswith("-"):
+ env_prefix.append(part)
+ cmd_start = i + 1
+ else:
+ break
+
+ if cmd_start >= len(parts):
+ return "none"
+
+ cmd_parts = parts[cmd_start:]
+ if not cmd_parts:
+ return "none"
+
+ first_word = cmd_parts[0]
+ two_word_commands = {
+ "git",
+ "npm",
+ "docker",
+ "kubectl",
+ "cargo",
+ "go",
+ "pip",
+ "yarn",
+ }
+
+ if first_word in two_word_commands and len(cmd_parts) > 1:
+ second_word = cmd_parts[1]
+ if not second_word.startswith("-"):
+ return f"{first_word} {second_word}"
+ return first_word
+ return first_word if not env_prefix else " ".join(env_prefix) + " " + first_word
+
+ except ValueError:
+ return command.split()[0] if command.split() else "none"
+
+
+def extract_filepaths_from_command(command: str, output: str) -> str:
+ """Extract file paths from a command locally without API call.
+
+ Determines if the command reads file contents and extracts paths accordingly.
+ Commands like ls/dir/find just list files, so return empty.
+ Commands like cat/head/tail actually read contents, so extract the file path.
+
+ Returns:
+ Filepath extraction result in format
+ """
+ listing_commands = {
+ "ls",
+ "dir",
+ "find",
+ "tree",
+ "pwd",
+ "cd",
+ "mkdir",
+ "rmdir",
+ "rm",
+ }
+
+ reading_commands = {"cat", "head", "tail", "less", "more", "bat", "type"}
+
+ try:
+ parts = shlex.split(command, posix=False)
+ if not parts:
+ return "\n"
+
+ base_cmd = parts[0].split("/")[-1].split("\\")[-1].lower()
+
+ if base_cmd in listing_commands:
+ return "\n"
+
+ if base_cmd in reading_commands:
+ filepaths = []
+ for part in parts[1:]:
+ if part.startswith("-"):
+ continue
+ filepaths.append(part)
+
+ if filepaths:
+ paths_str = "\n".join(filepaths)
+ return f"\n{paths_str}\n"
+ return "\n"
+
+ if base_cmd == "grep":
+ flags_with_args = {"-e", "-f", "-m", "-A", "-B", "-C"}
+ pattern_provided_via_flag = False
+ positional: list[str] = []
+
+ skip_next = False
+ for part in parts[1:]:
+ if skip_next:
+ skip_next = False
+ continue
+
+ if part.startswith("-"):
+ if part in flags_with_args:
+ if part in {"-e", "-f"}:
+ pattern_provided_via_flag = True
+ skip_next = True
+ continue
+
+ positional.append(part)
+
+ filepaths = positional if pattern_provided_via_flag else positional[1:]
+ if filepaths:
+ paths_str = "\n".join(filepaths)
+ return f"\n{paths_str}\n"
+ return "\n"
+
+ return "\n"
+
+ except Exception:
+ return "\n"
diff --git a/Claude_Code/api/dependencies.py b/Claude_Code/api/dependencies.py
new file mode 100644
index 0000000000000000000000000000000000000000..104f88ad7f03e479919c5a3a7943d9bffccb0635
--- /dev/null
+++ b/Claude_Code/api/dependencies.py
@@ -0,0 +1,226 @@
+"""Dependency injection for FastAPI."""
+
+from urllib.parse import unquote_plus
+
+from fastapi import Depends, HTTPException, Request
+from loguru import logger
+
+from config.settings import Settings
+from config.settings import get_settings as _get_settings
+from providers.base import BaseProvider, ProviderConfig
+from providers.common import get_user_facing_error_message
+from providers.exceptions import AuthenticationError
+from providers.llamacpp import LlamaCppProvider
+from providers.lmstudio import LMStudioProvider
+from providers.nvidia_nim import NVIDIA_NIM_BASE_URL, NvidiaNimProvider
+from providers.open_router import OPENROUTER_BASE_URL, OpenRouterProvider
+
+# Provider registry: keyed by provider type string, lazily populated
+_providers: dict[str, BaseProvider] = {}
+
+
+def get_settings() -> Settings:
+ """Get application settings via dependency injection."""
+ return _get_settings()
+
+
+def _create_provider_for_type(provider_type: str, settings: Settings) -> BaseProvider:
+ """Construct and return a new provider instance for the given provider type."""
+ if provider_type == "nvidia_nim":
+ if not settings.nvidia_nim_api_key or not settings.nvidia_nim_api_key.strip():
+ raise AuthenticationError(
+ "NVIDIA_NIM_API_KEY is not set. Add it to your .env file. "
+ "Get a key at https://build.nvidia.com/settings/api-keys"
+ )
+ config = ProviderConfig(
+ api_key=settings.nvidia_nim_api_key,
+ base_url=NVIDIA_NIM_BASE_URL,
+ rate_limit=settings.provider_rate_limit,
+ rate_window=settings.provider_rate_window,
+ max_concurrency=settings.provider_max_concurrency,
+ http_read_timeout=settings.http_read_timeout,
+ http_write_timeout=settings.http_write_timeout,
+ http_connect_timeout=settings.http_connect_timeout,
+ )
+ return NvidiaNimProvider(config, nim_settings=settings.nim)
+ if provider_type == "open_router":
+ if not settings.open_router_api_key or not settings.open_router_api_key.strip():
+ raise AuthenticationError(
+ "OPENROUTER_API_KEY is not set. Add it to your .env file. "
+ "Get a key at https://openrouter.ai/keys"
+ )
+ config = ProviderConfig(
+ api_key=settings.open_router_api_key,
+ base_url=OPENROUTER_BASE_URL,
+ rate_limit=settings.provider_rate_limit,
+ rate_window=settings.provider_rate_window,
+ max_concurrency=settings.provider_max_concurrency,
+ http_read_timeout=settings.http_read_timeout,
+ http_write_timeout=settings.http_write_timeout,
+ http_connect_timeout=settings.http_connect_timeout,
+ )
+ return OpenRouterProvider(config)
+ if provider_type == "lmstudio":
+ config = ProviderConfig(
+ api_key="lm-studio",
+ base_url=settings.lm_studio_base_url,
+ rate_limit=settings.provider_rate_limit,
+ rate_window=settings.provider_rate_window,
+ max_concurrency=settings.provider_max_concurrency,
+ http_read_timeout=settings.http_read_timeout,
+ http_write_timeout=settings.http_write_timeout,
+ http_connect_timeout=settings.http_connect_timeout,
+ )
+ return LMStudioProvider(config)
+ if provider_type == "llamacpp":
+ config = ProviderConfig(
+ api_key="llamacpp",
+ base_url=settings.llamacpp_base_url,
+ rate_limit=settings.provider_rate_limit,
+ rate_window=settings.provider_rate_window,
+ max_concurrency=settings.provider_max_concurrency,
+ http_read_timeout=settings.http_read_timeout,
+ http_write_timeout=settings.http_write_timeout,
+ http_connect_timeout=settings.http_connect_timeout,
+ )
+ return LlamaCppProvider(config)
+ logger.error(
+ "Unknown provider_type: '{}'. Supported: 'nvidia_nim', 'open_router', 'lmstudio', 'llamacpp'",
+ provider_type,
+ )
+ raise ValueError(
+ f"Unknown provider_type: '{provider_type}'. "
+ f"Supported: 'nvidia_nim', 'open_router', 'lmstudio', 'llamacpp'"
+ )
+
+
+def get_provider_for_type(provider_type: str) -> BaseProvider:
+ """Get or create a provider for the given provider type.
+
+ Providers are cached in the registry and reused across requests.
+ """
+ if provider_type not in _providers:
+ try:
+ _providers[provider_type] = _create_provider_for_type(
+ provider_type, get_settings()
+ )
+ except AuthenticationError as e:
+ raise HTTPException(
+ status_code=503, detail=get_user_facing_error_message(e)
+ ) from e
+ logger.info("Provider initialized: {}", provider_type)
+ return _providers[provider_type]
+
+
+def validate_request_api_key(request: Request, settings: Settings) -> None:
+ """Validate a request against configured server API key.
+
+ Checks `x-api-key` header, `Authorization: Bearer ...`, or query parameter `psw`
+ against `Settings.anthropic_auth_token`. If `ANTHROPIC_AUTH_TOKEN` is empty, this is a no-op.
+
+ Supports Hugging Face Spaces private deployments via query parameter authentication:
+ - Append `?psw=your-token` to the base URL
+ - Or `?psw:your-token` (URL-encoded colon becomes %3A)
+ """
+ anthropic_auth_token = settings.anthropic_auth_token
+ if not anthropic_auth_token:
+ # No API key configured -> allow
+ return
+
+ # Allow Hugging Face private Space signed browser requests for UI pages.
+ # This keeps API routes protected while avoiding 401 on Space shell probes.
+ if _is_hf_signed_page_request(request):
+ return
+
+ token = None
+
+ # Check headers first (preferred)
+ header = (
+ request.headers.get("x-api-key")
+ or request.headers.get("authorization")
+ or request.headers.get("anthropic-auth-token")
+ )
+ if header:
+ # Support both raw key in X-API-Key and Bearer token in Authorization
+ token = header
+ if header.lower().startswith("bearer "):
+ token = header.split(" ", 1)[1]
+ # Strip anything after the first colon to handle tokens with appended model names
+ if token and ":" in token:
+ token = token.split(":", 1)[0]
+ else:
+ token = _extract_query_token(request)
+
+ if not token:
+ raise HTTPException(status_code=401, detail="Missing API key")
+
+ if token != anthropic_auth_token:
+ raise HTTPException(status_code=401, detail="Invalid API key")
+
+
+def _extract_query_token(request: Request) -> str | None:
+ """Extract auth token from query string for private proxy deployments."""
+ query_params = request.query_params
+ if "psw" in query_params:
+ token = query_params["psw"]
+ if token and ":" in token:
+ return token.split(":", 1)[0]
+ return token or None
+
+ raw_query_bytes = request.scope.get("query_string", b"")
+ raw_query = raw_query_bytes.decode("utf-8", errors="ignore")
+ if not raw_query:
+ return None
+
+ for part in raw_query.split("&"):
+ if part.startswith("psw:"):
+ token = unquote_plus(part[len("psw:") :])
+ if token and ":" in token:
+ return token.split(":", 1)[0]
+ return token or None
+ if part.startswith("psw%3A") or part.startswith("psw%3a"):
+ token = unquote_plus(part[len("psw%3A") :])
+ if token and ":" in token:
+ return token.split(":", 1)[0]
+ return token or None
+
+ return None
+
+
+def _is_hf_signed_page_request(request: Request) -> bool:
+ """Return True for Hugging Face signed browser requests to non-API pages."""
+ if request.method not in {"GET", "HEAD"}:
+ return False
+
+ if request.url.path.startswith("/v1/"):
+ return False
+
+ if "__sign" not in request.query_params:
+ return False
+
+ accept = request.headers.get("accept", "").lower()
+ return "text/html" in accept or "*/*" in accept
+
+
+def require_api_key(
+ request: Request, settings: Settings = Depends(get_settings)
+) -> None:
+ """FastAPI dependency wrapper for API key validation."""
+ validate_request_api_key(request, settings)
+
+
+def get_provider() -> BaseProvider:
+ """Get or create the default provider (based on MODEL env var).
+
+ Backward-compatible convenience for health/root endpoints and tests.
+ """
+ return get_provider_for_type(get_settings().provider_type)
+
+
+async def cleanup_provider():
+ """Cleanup all provider resources."""
+ global _providers
+ for provider in _providers.values():
+ await provider.cleanup()
+ _providers = {}
+ logger.debug("Provider cleanup completed")
diff --git a/Claude_Code/api/detection.py b/Claude_Code/api/detection.py
new file mode 100644
index 0000000000000000000000000000000000000000..7df048aa5e9b2dd380687583968a515b624dbd41
--- /dev/null
+++ b/Claude_Code/api/detection.py
@@ -0,0 +1,130 @@
+"""Request detection utilities for API optimizations.
+
+Detects quota checks, title generation, prefix detection, suggestion mode,
+and filepath extraction requests to enable fast-path responses.
+"""
+
+from providers.common.text import extract_text_from_content
+
+from .models.anthropic import MessagesRequest
+
+
+def is_quota_check_request(request_data: MessagesRequest) -> bool:
+ """Check if this is a quota probe request.
+
+ Quota checks are typically simple requests with max_tokens=1
+ and a single message containing the word "quota".
+ """
+ if (
+ request_data.max_tokens == 1
+ and len(request_data.messages) == 1
+ and request_data.messages[0].role == "user"
+ ):
+ text = extract_text_from_content(request_data.messages[0].content)
+ if "quota" in text.lower():
+ return True
+ return False
+
+
+def is_title_generation_request(request_data: MessagesRequest) -> bool:
+ """Check if this is a conversation title generation request.
+
+ Title generation requests are detected by a system prompt containing
+ title extraction instructions, no tools, and a single user message.
+ """
+ if not request_data.system or request_data.tools:
+ return False
+ system_text = extract_text_from_content(request_data.system).lower()
+ return "new conversation topic" in system_text and "title" in system_text
+
+
+def is_prefix_detection_request(request_data: MessagesRequest) -> tuple[bool, str]:
+ """Check if this is a fast prefix detection request.
+
+ Prefix detection requests contain a policy_spec block and
+ a Command: section for extracting shell command prefixes.
+
+ Returns:
+ Tuple of (is_prefix_request, command_string)
+ """
+ if len(request_data.messages) != 1 or request_data.messages[0].role != "user":
+ return False, ""
+
+ content = extract_text_from_content(request_data.messages[0].content)
+
+ if "" in content and "Command:" in content:
+ try:
+ cmd_start = content.rfind("Command:") + len("Command:")
+ return True, content[cmd_start:].strip()
+ except Exception:
+ pass
+
+ return False, ""
+
+
+def is_suggestion_mode_request(request_data: MessagesRequest) -> bool:
+ """Check if this is a suggestion mode request.
+
+ Suggestion mode requests contain "[SUGGESTION MODE:" in the user's message,
+ used for auto-suggesting what the user might type next.
+ """
+ for msg in request_data.messages:
+ if msg.role == "user":
+ text = extract_text_from_content(msg.content)
+ if "[SUGGESTION MODE:" in text:
+ return True
+ return False
+
+
+def is_filepath_extraction_request(
+ request_data: MessagesRequest,
+) -> tuple[bool, str, str]:
+ """Check if this is a filepath extraction request.
+
+ Filepath extraction requests have a single user message with
+ "Command:" and "Output:" sections, asking to extract file paths
+ from command output.
+
+ Returns:
+ Tuple of (is_filepath_request, command, output)
+ """
+ if len(request_data.messages) != 1 or request_data.messages[0].role != "user":
+ return False, "", ""
+ if request_data.tools:
+ return False, "", ""
+
+ content = extract_text_from_content(request_data.messages[0].content)
+
+ if "Command:" not in content or "Output:" not in content:
+ return False, "", ""
+
+ # Match if user content OR system block indicates filepath extraction
+ user_has_filepaths = (
+ "filepaths" in content.lower() or "" in content.lower()
+ )
+ system_text = (
+ extract_text_from_content(request_data.system) if request_data.system else ""
+ )
+ system_has_extract = (
+ "extract any file paths" in system_text.lower()
+ or "file paths that this command" in system_text.lower()
+ )
+ if not user_has_filepaths and not system_has_extract:
+ return False, "", ""
+
+ try:
+ cmd_start = content.find("Command:") + len("Command:")
+ output_marker = content.find("Output:", cmd_start)
+ if output_marker == -1:
+ return False, "", ""
+
+ command = content[cmd_start:output_marker].strip()
+ output = content[output_marker + len("Output:") :].strip()
+
+ for marker in ["<", "\n\n"]:
+ if marker in output:
+ output = output.split(marker)[0].strip()
+
+ return True, command, output
+ except Exception:
+ return False, "", ""
diff --git a/Claude_Code/api/models/__init__.py b/Claude_Code/api/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..08428a97d6c092d7445f7018b763f1e7688c9a73
--- /dev/null
+++ b/Claude_Code/api/models/__init__.py
@@ -0,0 +1,35 @@
+"""API models exports."""
+
+from .anthropic import (
+ ContentBlockImage,
+ ContentBlockText,
+ ContentBlockThinking,
+ ContentBlockToolResult,
+ ContentBlockToolUse,
+ Message,
+ MessagesRequest,
+ Role,
+ SystemContent,
+ ThinkingConfig,
+ TokenCountRequest,
+ Tool,
+)
+from .responses import MessagesResponse, TokenCountResponse, Usage
+
+__all__ = [
+ "ContentBlockImage",
+ "ContentBlockText",
+ "ContentBlockThinking",
+ "ContentBlockToolResult",
+ "ContentBlockToolUse",
+ "Message",
+ "MessagesRequest",
+ "MessagesResponse",
+ "Role",
+ "SystemContent",
+ "ThinkingConfig",
+ "TokenCountRequest",
+ "TokenCountResponse",
+ "Tool",
+ "Usage",
+]
diff --git a/Claude_Code/api/models/anthropic.py b/Claude_Code/api/models/anthropic.py
new file mode 100644
index 0000000000000000000000000000000000000000..a96eef37dfe867468af5375a6cb342f9e5cea62c
--- /dev/null
+++ b/Claude_Code/api/models/anthropic.py
@@ -0,0 +1,134 @@
+"""Pydantic models for Anthropic-compatible requests."""
+
+from enum import StrEnum
+from typing import Any, Literal
+
+from loguru import logger
+from pydantic import BaseModel, field_validator, model_validator
+
+from config.settings import Settings, get_settings
+
+
+# =============================================================================
+# Content Block Types
+# =============================================================================
+class Role(StrEnum):
+ user = "user"
+ assistant = "assistant"
+ system = "system"
+
+
+class ContentBlockText(BaseModel):
+ type: Literal["text"]
+ text: str
+
+
+class ContentBlockImage(BaseModel):
+ type: Literal["image"]
+ source: dict[str, Any]
+
+
+class ContentBlockToolUse(BaseModel):
+ type: Literal["tool_use"]
+ id: str
+ name: str
+ input: dict[str, Any]
+
+
+class ContentBlockToolResult(BaseModel):
+ type: Literal["tool_result"]
+ tool_use_id: str
+ content: str | list[Any] | dict[str, Any]
+
+
+class ContentBlockThinking(BaseModel):
+ type: Literal["thinking"]
+ thinking: str
+
+
+class SystemContent(BaseModel):
+ type: Literal["text"]
+ text: str
+
+
+# =============================================================================
+# Message Types
+# =============================================================================
+class Message(BaseModel):
+ role: Literal["user", "assistant"]
+ content: (
+ str
+ | list[
+ ContentBlockText
+ | ContentBlockImage
+ | ContentBlockToolUse
+ | ContentBlockToolResult
+ | ContentBlockThinking
+ ]
+ )
+ reasoning_content: str | None = None
+
+
+class Tool(BaseModel):
+ name: str
+ description: str | None = None
+ input_schema: dict[str, Any]
+
+
+class ThinkingConfig(BaseModel):
+ enabled: bool = True
+
+
+# =============================================================================
+# Request Models
+# =============================================================================
+class MessagesRequest(BaseModel):
+ model: str
+ max_tokens: int | None = None
+ messages: list[Message]
+ system: str | list[SystemContent] | None = None
+ stop_sequences: list[str] | None = None
+ stream: bool | None = True
+ temperature: float | None = None
+ top_p: float | None = None
+ top_k: int | None = None
+ metadata: dict[str, Any] | None = None
+ tools: list[Tool] | None = None
+ tool_choice: dict[str, Any] | None = None
+ thinking: ThinkingConfig | None = None
+ extra_body: dict[str, Any] | None = None
+ original_model: str | None = None
+ resolved_provider_model: str | None = None
+
+ @model_validator(mode="after")
+ def map_model(self) -> "MessagesRequest":
+ """Map any Claude model name to the configured model (model-aware)."""
+ settings = get_settings()
+ if self.original_model is None:
+ self.original_model = self.model
+
+ resolved_full = settings.resolve_model(self.original_model)
+ self.resolved_provider_model = resolved_full
+ self.model = Settings.parse_model_name(resolved_full)
+
+ if self.model != self.original_model:
+ logger.debug(f"MODEL MAPPING: '{self.original_model}' -> '{self.model}'")
+
+ return self
+
+
+class TokenCountRequest(BaseModel):
+ model: str
+ messages: list[Message]
+ system: str | list[SystemContent] | None = None
+ tools: list[Tool] | None = None
+ thinking: ThinkingConfig | None = None
+ tool_choice: dict[str, Any] | None = None
+
+ @field_validator("model")
+ @classmethod
+ def validate_model_field(cls, v: str, info) -> str:
+ """Map any Claude model name to the configured model (model-aware)."""
+ settings = get_settings()
+ resolved_full = settings.resolve_model(v)
+ return Settings.parse_model_name(resolved_full)
diff --git a/Claude_Code/api/models/responses.py b/Claude_Code/api/models/responses.py
new file mode 100644
index 0000000000000000000000000000000000000000..40a7d7b76651c44b89f2ca89ec6a0e50756305ac
--- /dev/null
+++ b/Claude_Code/api/models/responses.py
@@ -0,0 +1,33 @@
+"""Pydantic models for API responses."""
+
+from typing import Any, Literal
+
+from pydantic import BaseModel
+
+from .anthropic import ContentBlockText, ContentBlockThinking, ContentBlockToolUse
+
+
+class TokenCountResponse(BaseModel):
+ input_tokens: int
+
+
+class Usage(BaseModel):
+ input_tokens: int
+ output_tokens: int
+ cache_creation_input_tokens: int = 0
+ cache_read_input_tokens: int = 0
+
+
+class MessagesResponse(BaseModel):
+ id: str
+ model: str
+ role: Literal["assistant"] = "assistant"
+ content: list[
+ ContentBlockText | ContentBlockToolUse | ContentBlockThinking | dict[str, Any]
+ ]
+ type: Literal["message"] = "message"
+ stop_reason: (
+ Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None
+ ) = None
+ stop_sequence: str | None = None
+ usage: Usage
diff --git a/Claude_Code/api/optimization_handlers.py b/Claude_Code/api/optimization_handlers.py
new file mode 100644
index 0000000000000000000000000000000000000000..e01517eab44dd39bcd4c298f70e92bd2ea8d1a5c
--- /dev/null
+++ b/Claude_Code/api/optimization_handlers.py
@@ -0,0 +1,147 @@
+"""Optimization handlers for fast-path API responses.
+
+Each handler returns a MessagesResponse if the request matches and the
+optimization is enabled, otherwise None.
+"""
+
+import uuid
+
+from loguru import logger
+
+from config.settings import Settings
+
+from .command_utils import extract_command_prefix, extract_filepaths_from_command
+from .detection import (
+ is_filepath_extraction_request,
+ is_prefix_detection_request,
+ is_quota_check_request,
+ is_suggestion_mode_request,
+ is_title_generation_request,
+)
+from .models.anthropic import MessagesRequest
+from .models.responses import MessagesResponse, Usage
+
+
+def try_prefix_detection(
+ request_data: MessagesRequest, settings: Settings
+) -> MessagesResponse | None:
+ """Fast prefix detection - return command prefix without API call."""
+ if not settings.fast_prefix_detection:
+ return None
+
+ is_prefix_req, command = is_prefix_detection_request(request_data)
+ if not is_prefix_req:
+ return None
+
+ logger.info("Optimization: Fast prefix detection request")
+ return MessagesResponse(
+ id=f"msg_{uuid.uuid4()}",
+ model=request_data.model,
+ content=[{"type": "text", "text": extract_command_prefix(command)}],
+ stop_reason="end_turn",
+ usage=Usage(input_tokens=100, output_tokens=5),
+ )
+
+
+def try_quota_mock(
+ request_data: MessagesRequest, settings: Settings
+) -> MessagesResponse | None:
+ """Mock quota probe requests."""
+ if not settings.enable_network_probe_mock:
+ return None
+ if not is_quota_check_request(request_data):
+ return None
+
+ logger.info("Optimization: Intercepted and mocked quota probe")
+ return MessagesResponse(
+ id=f"msg_{uuid.uuid4()}",
+ model=request_data.model,
+ role="assistant",
+ content=[{"type": "text", "text": "Quota check passed."}],
+ stop_reason="end_turn",
+ usage=Usage(input_tokens=10, output_tokens=5),
+ )
+
+
+def try_title_skip(
+ request_data: MessagesRequest, settings: Settings
+) -> MessagesResponse | None:
+ """Skip title generation requests."""
+ if not settings.enable_title_generation_skip:
+ return None
+ if not is_title_generation_request(request_data):
+ return None
+
+ logger.info("Optimization: Skipped title generation request")
+ return MessagesResponse(
+ id=f"msg_{uuid.uuid4()}",
+ model=request_data.model,
+ role="assistant",
+ content=[{"type": "text", "text": "Conversation"}],
+ stop_reason="end_turn",
+ usage=Usage(input_tokens=100, output_tokens=5),
+ )
+
+
+def try_suggestion_skip(
+ request_data: MessagesRequest, settings: Settings
+) -> MessagesResponse | None:
+ """Skip suggestion mode requests."""
+ if not settings.enable_suggestion_mode_skip:
+ return None
+ if not is_suggestion_mode_request(request_data):
+ return None
+
+ logger.info("Optimization: Skipped suggestion mode request")
+ return MessagesResponse(
+ id=f"msg_{uuid.uuid4()}",
+ model=request_data.model,
+ role="assistant",
+ content=[{"type": "text", "text": ""}],
+ stop_reason="end_turn",
+ usage=Usage(input_tokens=100, output_tokens=1),
+ )
+
+
+def try_filepath_mock(
+ request_data: MessagesRequest, settings: Settings
+) -> MessagesResponse | None:
+ """Mock filepath extraction requests."""
+ if not settings.enable_filepath_extraction_mock:
+ return None
+
+ is_fp, cmd, output = is_filepath_extraction_request(request_data)
+ if not is_fp:
+ return None
+
+ filepaths = extract_filepaths_from_command(cmd, output)
+ logger.info("Optimization: Mocked filepath extraction")
+ return MessagesResponse(
+ id=f"msg_{uuid.uuid4()}",
+ model=request_data.model,
+ role="assistant",
+ content=[{"type": "text", "text": filepaths}],
+ stop_reason="end_turn",
+ usage=Usage(input_tokens=100, output_tokens=10),
+ )
+
+
+# Cheapest/most common optimizations first for faster short-circuit.
+OPTIMIZATION_HANDLERS = [
+ try_quota_mock,
+ try_prefix_detection,
+ try_title_skip,
+ try_suggestion_skip,
+ try_filepath_mock,
+]
+
+
+def try_optimizations(
+ request_data: MessagesRequest, settings: Settings
+) -> MessagesResponse | None:
+ """Run optimization handlers in order. Returns first match or None."""
+ for handler in OPTIMIZATION_HANDLERS:
+ result = handler(request_data, settings)
+ if result is not None:
+ return result
+ return None
diff --git a/Claude_Code/api/request_utils.py b/Claude_Code/api/request_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e8c1549df3404be478076ade8904d3ebe25e99e
--- /dev/null
+++ b/Claude_Code/api/request_utils.py
@@ -0,0 +1,101 @@
+"""Request utility functions for API route handlers.
+
+Contains token counting for API requests.
+"""
+
+import json
+
+import tiktoken
+from loguru import logger
+
+from providers.common import get_block_attr
+
+ENCODER = tiktoken.get_encoding("cl100k_base")
+
+__all__ = ["get_token_count"]
+
+
+def get_token_count(
+ messages: list,
+ system: str | list | None = None,
+ tools: list | None = None,
+) -> int:
+ """Estimate token count for a request.
+
+ Uses tiktoken cl100k_base encoding to estimate token usage.
+ Includes system prompt, messages, tools, and per-message overhead.
+ """
+ total_tokens = 0
+
+ if system:
+ if isinstance(system, str):
+ total_tokens += len(ENCODER.encode(system))
+ elif isinstance(system, list):
+ for block in system:
+ text = get_block_attr(block, "text", "")
+ if text:
+ total_tokens += len(ENCODER.encode(str(text)))
+ total_tokens += 4 # System block formatting overhead
+
+ for msg in messages:
+ if isinstance(msg.content, str):
+ total_tokens += len(ENCODER.encode(msg.content))
+ elif isinstance(msg.content, list):
+ for block in msg.content:
+ b_type = get_block_attr(block, "type") or None
+
+ if b_type == "text":
+ text = get_block_attr(block, "text", "")
+ total_tokens += len(ENCODER.encode(str(text)))
+ elif b_type == "thinking":
+ thinking = get_block_attr(block, "thinking", "")
+ total_tokens += len(ENCODER.encode(str(thinking)))
+ elif b_type == "tool_use":
+ name = get_block_attr(block, "name", "")
+ inp = get_block_attr(block, "input", {})
+ block_id = get_block_attr(block, "id", "")
+ total_tokens += len(ENCODER.encode(str(name)))
+ total_tokens += len(ENCODER.encode(json.dumps(inp)))
+ total_tokens += len(ENCODER.encode(str(block_id)))
+ total_tokens += 15
+ elif b_type == "image":
+ source = get_block_attr(block, "source")
+ if isinstance(source, dict):
+ data = source.get("data") or source.get("base64") or ""
+ if data:
+ total_tokens += max(85, len(data) // 3000)
+ else:
+ total_tokens += 765
+ else:
+ total_tokens += 765
+ elif b_type == "tool_result":
+ content = get_block_attr(block, "content", "")
+ tool_use_id = get_block_attr(block, "tool_use_id", "")
+ if isinstance(content, str):
+ total_tokens += len(ENCODER.encode(content))
+ else:
+ total_tokens += len(ENCODER.encode(json.dumps(content)))
+ total_tokens += len(ENCODER.encode(str(tool_use_id)))
+ total_tokens += 8
+ else:
+ logger.debug(
+ "Unexpected block type %r, falling back to json/str encoding",
+ b_type,
+ )
+ try:
+ total_tokens += len(ENCODER.encode(json.dumps(block)))
+ except TypeError, ValueError:
+ total_tokens += len(ENCODER.encode(str(block)))
+
+ if tools:
+ for tool in tools:
+ tool_str = (
+ tool.name + (tool.description or "") + json.dumps(tool.input_schema)
+ )
+ total_tokens += len(ENCODER.encode(tool_str))
+
+ total_tokens += len(messages) * 4
+ if tools:
+ total_tokens += len(tools) * 5
+
+ return max(1, total_tokens)
diff --git a/Claude_Code/api/routes.py b/Claude_Code/api/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..2d8329a2471dab88ee4fb02a21c5ccd94c475176
--- /dev/null
+++ b/Claude_Code/api/routes.py
@@ -0,0 +1,345 @@
+"""FastAPI route handlers."""
+
+import os
+import shutil
+import tempfile
+import time
+import traceback
+import uuid
+from pathlib import Path
+
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
+from fastapi.responses import HTMLResponse, StreamingResponse
+from loguru import logger
+
+from config.settings import Settings
+from providers.common import get_user_facing_error_message
+from providers.exceptions import InvalidRequestError, ProviderError
+
+from .dependencies import get_provider_for_type, get_settings, require_api_key
+from .models.anthropic import MessagesRequest, TokenCountRequest
+from .models.responses import TokenCountResponse
+from .optimization_handlers import try_optimizations
+from .request_utils import get_token_count
+
+router = APIRouter()
+
+
+def _home_page_html(status_payload: dict[str, str]) -> str:
+ """Render the home page HTML with the factory reset button."""
+ return f"""
+
+
+
+
+
+ Claude Code Proxy
+
+
+
+
+
Claude Code Proxy
+
Server is running.
+
+ Status: {status_payload['status']}
+ Provider: {status_payload['provider']}
+ Model: {status_payload['model']}
+
+
+
+
+
+
+
+"""
+
+
+def _clear_path(path: Path) -> int:
+ """Best-effort removal of a file/directory path. Returns removed item count."""
+ if not path.exists():
+ return 0
+ try:
+ if path.is_dir():
+ shutil.rmtree(path)
+ else:
+ path.unlink()
+ return 1
+ except Exception as e:
+ logger.warning("Failed to remove path {}: {}", path, e)
+ return 0
+
+
+def _clear_workspace_contents(workspace: Path) -> int:
+ """Best-effort clear of workspace contents while preserving root directory."""
+ if not workspace.exists() or not workspace.is_dir():
+ return 0
+ removed = 0
+ for child in workspace.iterdir():
+ removed += _clear_path(child)
+ return removed
+
+
+def _clear_runtime_state(settings: Settings) -> dict[str, int]:
+ """Clear runtime caches/workspace data for a lightweight factory reset."""
+ removed = {
+ "workspace_items": 0,
+ "cache_dirs": 0,
+ "pycache_dirs": 0,
+ }
+
+ workspace = Path(settings.claude_workspace).expanduser().resolve()
+ removed["workspace_items"] = _clear_workspace_contents(workspace)
+
+ cache_dirs = [
+ Path.home() / ".cache" / "huggingface",
+ Path.home() / ".cache" / "uv",
+ Path.home() / ".cache" / "pip",
+ Path(tempfile.gettempdir()) / "huggingface",
+ ]
+ for cache_dir in cache_dirs:
+ removed["cache_dirs"] += _clear_path(cache_dir)
+
+ project_root = Path.cwd()
+ for pycache_dir in project_root.rglob("__pycache__"):
+ if ".venv" in pycache_dir.parts:
+ continue
+ removed["pycache_dirs"] += _clear_path(pycache_dir)
+
+ return removed
+
+
+def _restart_process() -> None:
+ """Terminate process so container orchestrator restarts the app."""
+ logger.warning("Factory reset requested: restarting process")
+ time.sleep(1.0)
+ os._exit(0)
+
+
+# =============================================================================
+# Routes
+# =============================================================================
+@router.post("/v1/messages")
+async def create_message(
+ request_data: MessagesRequest,
+ raw_request: Request,
+ settings: Settings = Depends(get_settings),
+ _auth=Depends(require_api_key),
+):
+ """Create a message (always streaming)."""
+
+ try:
+ if not request_data.messages:
+ raise InvalidRequestError("messages cannot be empty")
+
+ optimized = try_optimizations(request_data, settings)
+ if optimized is not None:
+ return optimized
+ logger.debug("No optimization matched, routing to provider")
+
+ # Resolve provider from the model-aware mapping
+ provider_type = Settings.parse_provider_type(
+ request_data.resolved_provider_model or settings.model
+ )
+ provider = get_provider_for_type(provider_type)
+
+ request_id = f"req_{uuid.uuid4().hex[:12]}"
+ logger.info(
+ "API_REQUEST: request_id={} model={} messages={}",
+ request_id,
+ request_data.model,
+ len(request_data.messages),
+ )
+ logger.debug("FULL_PAYLOAD [{}]: {}", request_id, request_data.model_dump())
+
+ input_tokens = get_token_count(
+ request_data.messages, request_data.system, request_data.tools
+ )
+ return StreamingResponse(
+ provider.stream_response(
+ request_data,
+ input_tokens=input_tokens,
+ request_id=request_id,
+ ),
+ media_type="text/event-stream",
+ headers={
+ "X-Accel-Buffering": "no",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ },
+ )
+
+ except ProviderError:
+ raise
+ except Exception as e:
+ logger.error(f"Error: {e!s}\n{traceback.format_exc()}")
+ raise HTTPException(
+ status_code=getattr(e, "status_code", 500),
+ detail=get_user_facing_error_message(e),
+ ) from e
+
+
+@router.post("/v1/messages/count_tokens")
+async def count_tokens(request_data: TokenCountRequest, _auth=Depends(require_api_key)):
+ """Count tokens for a request."""
+ request_id = f"req_{uuid.uuid4().hex[:12]}"
+ with logger.contextualize(request_id=request_id):
+ try:
+ tokens = get_token_count(
+ request_data.messages, request_data.system, request_data.tools
+ )
+ logger.info(
+ "COUNT_TOKENS: request_id={} model={} messages={} input_tokens={}",
+ request_id,
+ getattr(request_data, "model", "unknown"),
+ len(request_data.messages),
+ tokens,
+ )
+ return TokenCountResponse(input_tokens=tokens)
+ except Exception as e:
+ logger.error(
+ "COUNT_TOKENS_ERROR: request_id={} error={}\n{}",
+ request_id,
+ get_user_facing_error_message(e),
+ traceback.format_exc(),
+ )
+ raise HTTPException(
+ status_code=500, detail=get_user_facing_error_message(e)
+ ) from e
+
+
+@router.get("/")
+async def root(
+ request: Request,
+ settings: Settings = Depends(get_settings),
+ _auth=Depends(require_api_key),
+):
+ """Root endpoint (JSON for API clients, HTML for browsers)."""
+ payload = {
+ "status": "ok",
+ "provider": settings.provider_type,
+ "model": settings.model,
+ }
+ accept = request.headers.get("accept", "")
+ if "__sign" in request.query_params or "text/html" in accept.lower():
+ return HTMLResponse(content=_home_page_html(payload))
+ return payload
+
+
+@router.get("/health")
+async def health():
+ """Health check endpoint."""
+ return {"status": "healthy"}
+
+
+@router.post("/stop")
+async def stop_cli(request: Request, _auth=Depends(require_api_key)):
+ """Stop all CLI sessions and pending tasks."""
+ handler = getattr(request.app.state, "message_handler", None)
+ if not handler:
+ # Fallback if messaging not initialized
+ cli_manager = getattr(request.app.state, "cli_manager", None)
+ if cli_manager:
+ await cli_manager.stop_all()
+ logger.info("STOP_CLI: source=cli_manager cancelled_count=N/A")
+ return {"status": "stopped", "source": "cli_manager"}
+ raise HTTPException(status_code=503, detail="Messaging system not initialized")
+
+ count = await handler.stop_all_tasks()
+ logger.info("STOP_CLI: source=handler cancelled_count={}", count)
+ return {"status": "stopped", "cancelled_count": count}
+
+
+@router.get("/admin/factory-reset", response_class=HTMLResponse)
+async def factory_reset_page(request: Request, _auth=Depends(require_api_key)):
+ """Simple admin UI for one-click factory reset and restart."""
+ return """
+
+
+
+
+
+ Factory Reset
+
+
+
+
+
Factory Reset & Restart
+
Clears runtime cache and workspace data, then restarts this server.
+
+
+
+
+
+
+"""
+
+
+@router.post("/admin/factory-reset")
+async def factory_reset(
+ background_tasks: BackgroundTasks,
+ settings: Settings = Depends(get_settings),
+ _auth=Depends(require_api_key),
+):
+ """Clear runtime state and restart process (for Space maintenance)."""
+ cleared = _clear_runtime_state(settings)
+ background_tasks.add_task(_restart_process)
+ return {
+ "status": "restarting",
+ "cleared": cleared,
+ }
diff --git a/Claude_Code/claude-pick b/Claude_Code/claude-pick
new file mode 100644
index 0000000000000000000000000000000000000000..792c6bd722a666466df8988e59ac07c06178ca6e
--- /dev/null
+++ b/Claude_Code/claude-pick
@@ -0,0 +1,183 @@
+#!/usr/bin/env bash
+# claude-pick — Interactive model picker for free-claude-code
+# Usage: claude-pick [extra claude args...]
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MODELS_FILE="$SCRIPT_DIR/nvidia_nim_models.json"
+ENV_FILE="${CLAUDE_PICK_ENV_FILE:-$SCRIPT_DIR/.env}"
+PORT="${CLAUDE_PICK_PORT:-8082}"
+BASE_URL="http://localhost:$PORT"
+OPENROUTER_MODELS_URL="https://openrouter.ai/api/v1/models"
+DEFAULT_LM_STUDIO_BASE_URL="http://localhost:1234/v1"
+DEFAULT_LLAMACPP_BASE_URL="http://localhost:8080/v1"
+
+if ! command -v python3 >/dev/null 2>&1; then
+ echo "Error: python3 is required." >&2
+ exit 1
+fi
+
+read_env_value() {
+ local key="$1"
+ [[ -f "$ENV_FILE" ]] || return 0
+
+ local raw
+ raw="$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$ENV_FILE" | tail -n 1 || true)"
+ raw="${raw#*=}"
+ raw="${raw%%#*}"
+ raw="$(echo "$raw" | xargs || true)"
+ raw="${raw%\"}"
+ raw="${raw#\"}"
+ raw="${raw%\'}"
+ raw="${raw#\'}"
+ echo "$raw"
+}
+
+if ! command -v fzf >/dev/null 2>&1; then
+ echo "Error: fzf is required for the model picker." >&2
+ echo "Install it from: https://github.com/junegunn/fzf" >&2
+ exit 1
+fi
+
+parse_models_from_json() {
+ python3 -c '
+import json, sys
+try:
+ payload = json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+for item in payload.get("data", []):
+ model_id = item.get("id")
+ if model_id:
+ print(model_id)
+'
+}
+
+get_nvidia_models() {
+ if [[ ! -f "$MODELS_FILE" ]]; then
+ echo "Error: $MODELS_FILE not found." >&2
+ echo "Run: curl \"https://integrate.api.nvidia.com/v1/models\" > nvidia_nim_models.json" >&2
+ exit 1
+ fi
+
+ python3 -c '
+import json, sys
+with open(sys.argv[1], "r", encoding="utf-8") as f:
+ payload = json.load(f)
+for item in payload.get("data", []):
+ model_id = item.get("id")
+ if model_id:
+ print(model_id)
+' "$MODELS_FILE"
+}
+
+get_openrouter_models() {
+ if ! command -v curl >/dev/null 2>&1; then
+ echo "Error: curl is required for OpenRouter model discovery." >&2
+ exit 1
+ fi
+
+ local openrouter_key
+ openrouter_key="${OPENROUTER_API_KEY:-$(read_env_value OPENROUTER_API_KEY)}"
+
+ local response
+ if [[ -n "$openrouter_key" ]]; then
+ if ! response="$(curl -fsSL -H "Authorization: Bearer $openrouter_key" "$OPENROUTER_MODELS_URL")"; then
+ echo "Error: Failed to fetch OpenRouter models." >&2
+ exit 1
+ fi
+ else
+ if ! response="$(curl -fsSL "$OPENROUTER_MODELS_URL")"; then
+ echo "Error: Failed to fetch OpenRouter models." >&2
+ exit 1
+ fi
+ fi
+
+ parse_models_from_json <<< "$response"
+}
+
+get_lmstudio_models() {
+ if ! command -v curl >/dev/null 2>&1; then
+ echo "Error: curl is required for LM Studio model discovery." >&2
+ exit 1
+ fi
+
+ local lm_base
+ lm_base="${LM_STUDIO_BASE_URL:-$(read_env_value LM_STUDIO_BASE_URL)}"
+ lm_base="${lm_base:-$DEFAULT_LM_STUDIO_BASE_URL}"
+
+ local models_url
+ if [[ "$lm_base" == */v1 ]]; then
+ models_url="${lm_base}/models"
+ else
+ models_url="${lm_base}/v1/models"
+ fi
+
+ local response
+ if ! response="$(curl -fsSL "$models_url")"; then
+ echo "Error: Failed to query LM Studio models at $models_url" >&2
+ echo "Start LM Studio server first (Developer tab or: lms server start)." >&2
+ exit 1
+ fi
+
+ parse_models_from_json <<< "$response"
+}
+
+provider="${CLAUDE_PICK_PROVIDER:-$(read_env_value PROVIDER_TYPE)}"
+provider="${provider:-nvidia_nim}"
+
+prompt="Select a model> "
+case "$provider" in
+ nvidia_nim)
+ models="$(get_nvidia_models)"
+ prompt="Select a NVIDIA NIM model> "
+ ;;
+ open_router|openrouter)
+ models="$(get_openrouter_models)"
+ prompt="Select an OpenRouter model> "
+ ;;
+ lmstudio|lm_studio|lm-studio)
+ models="$(get_lmstudio_models)"
+ prompt="Select an LM Studio model> "
+ ;;
+ llamacpp|llama.cpp)
+ # llama.cpp doesn't have a standardized /models endpoint that returns all loaded models reliably
+ # in the same way, but it does support Anthropic routing. We can use a stub model or query if available.
+ # For simple picker, we'll just allow passing a default or typing it in, but to match fzf we offer a stub.
+ models="local-model\nllama-server"
+ prompt="Select a llama.cpp model> "
+ ;;
+ *)
+ echo "Error: Unsupported PROVIDER_TYPE='$provider'." >&2
+ echo "Expected one of: nvidia_nim, open_router, lmstudio, llamacpp" >&2
+ exit 1
+ ;;
+esac
+
+models="$(printf "%s\n" "$models" | sed '/^[[:space:]]*$/d' | sort -u)"
+if [[ -z "$models" ]]; then
+ echo "Error: No models found for provider '$provider'." >&2
+ exit 1
+fi
+
+model="$(printf "%s\n" "$models" | fzf --prompt="$prompt" --height=40% --reverse)"
+
+if [[ -z "${model:-}" ]]; then
+ echo "No model selected." >&2
+ exit 1
+fi
+
+# Read auth token from .env or environment
+auth_token="${ANTHROPIC_AUTH_TOKEN:-$(read_env_value ANTHROPIC_AUTH_TOKEN)}"
+if [[ -z "$auth_token" ]]; then
+ auth_token="freecc"
+fi
+
+# If auth_token doesn't contain a colon, append ":$model"
+if [[ "$auth_token" != *:* ]]; then
+ auth_token="$auth_token:$model"
+fi
+
+echo "Launching Claude with provider: $provider, model: $model" >&2
+ANTHROPIC_AUTH_TOKEN="$auth_token" ANTHROPIC_BASE_URL="$BASE_URL" exec claude "$@"
diff --git a/Claude_Code/cli/__init__.py b/Claude_Code/cli/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2df9787dfba001bcca824a3d51c362931bde994c
--- /dev/null
+++ b/Claude_Code/cli/__init__.py
@@ -0,0 +1,6 @@
+"""CLI integration for Claude Code."""
+
+from .manager import CLISessionManager
+from .session import CLISession
+
+__all__ = ["CLISession", "CLISessionManager"]
diff --git a/Claude_Code/cli/entrypoints.py b/Claude_Code/cli/entrypoints.py
new file mode 100644
index 0000000000000000000000000000000000000000..92f54e1d4cf18c15759186324443a4378af5f7db
--- /dev/null
+++ b/Claude_Code/cli/entrypoints.py
@@ -0,0 +1,47 @@
+"""CLI entry points for the installed package."""
+
+from __future__ import annotations
+
+
+def serve() -> None:
+ """Start the FastAPI server (registered as `free-claude-code` script)."""
+ import uvicorn
+
+ from cli.process_registry import kill_all_best_effort
+ from config.settings import get_settings
+
+ settings = get_settings()
+ try:
+ uvicorn.run(
+ "api.app:app",
+ host=settings.host,
+ port=settings.port,
+ log_level="debug",
+ timeout_graceful_shutdown=5,
+ )
+ finally:
+ kill_all_best_effort()
+
+
+def init() -> None:
+ """Scaffold config at ~/.config/free-claude-code/.env (registered as `fcc-init`)."""
+ import importlib.resources
+ from pathlib import Path
+
+ config_dir = Path.home() / ".config" / "free-claude-code"
+ env_file = config_dir / ".env"
+
+ if env_file.exists():
+ print(f"Config already exists at {env_file}")
+ print("Delete it first if you want to reset to defaults.")
+ return
+
+ config_dir.mkdir(parents=True, exist_ok=True)
+ template = (
+ importlib.resources.files("config").joinpath("env.example").read_text("utf-8")
+ )
+ env_file.write_text(template, encoding="utf-8")
+ print(f"Config created at {env_file}")
+ print(
+ "Edit it to set your API keys and model preferences, then run: free-claude-code"
+ )
diff --git a/Claude_Code/cli/manager.py b/Claude_Code/cli/manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..48d38493c04b7e8fbd2f2490b7e7730381e956a3
--- /dev/null
+++ b/Claude_Code/cli/manager.py
@@ -0,0 +1,144 @@
+"""
+CLI Session Manager for Multi-Instance Claude CLI Support
+
+Manages a pool of CLISession instances, each handling one conversation.
+This enables true parallel processing where multiple conversations run
+simultaneously in separate CLI processes.
+"""
+
+import asyncio
+import uuid
+
+from loguru import logger
+
+from .session import CLISession
+
+
+class CLISessionManager:
+ """
+ Manages multiple CLISession instances for parallel conversation processing.
+
+ Each new conversation gets its own CLISession with its own subprocess.
+ Replies to existing conversations reuse the same CLISession instance.
+ """
+
+ def __init__(
+ self,
+ workspace_path: str,
+ api_url: str,
+ allowed_dirs: list[str] | None = None,
+ plans_directory: str | None = None,
+ ):
+ """
+ Initialize the session manager.
+
+ Args:
+ workspace_path: Working directory for CLI processes
+ api_url: API URL for the proxy
+ allowed_dirs: Directories the CLI is allowed to access
+ plans_directory: Directory for Claude Code CLI plan files (passed via --settings)
+ """
+ self.workspace = workspace_path
+ self.api_url = api_url
+ self.allowed_dirs = allowed_dirs or []
+ self.plans_directory = plans_directory
+
+ self._sessions: dict[str, CLISession] = {}
+ self._pending_sessions: dict[str, CLISession] = {}
+ self._temp_to_real: dict[str, str] = {}
+ self._real_to_temp: dict[str, str] = {}
+ self._lock = asyncio.Lock()
+
+ logger.info("CLISessionManager initialized")
+
+ async def get_or_create_session(
+ self, session_id: str | None = None
+ ) -> tuple[CLISession, str, bool]:
+ """
+ Get an existing session or create a new one.
+
+ Returns:
+ Tuple of (CLISession instance, session_id, is_new_session)
+ """
+ async with self._lock:
+ if session_id:
+ lookup_id = self._temp_to_real.get(session_id, session_id)
+
+ if lookup_id in self._sessions:
+ return self._sessions[lookup_id], lookup_id, False
+ if lookup_id in self._pending_sessions:
+ return self._pending_sessions[lookup_id], lookup_id, False
+
+ temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"
+
+ new_session = CLISession(
+ workspace_path=self.workspace,
+ api_url=self.api_url,
+ allowed_dirs=self.allowed_dirs,
+ plans_directory=self.plans_directory,
+ )
+ self._pending_sessions[temp_id] = new_session
+ logger.info(f"Created new session: {temp_id}")
+
+ return new_session, temp_id, True
+
+ async def register_real_session_id(
+ self, temp_id: str, real_session_id: str
+ ) -> bool:
+ """Register the real session ID from CLI output."""
+ async with self._lock:
+ if temp_id not in self._pending_sessions:
+ logger.warning(f"Temp session {temp_id} not found")
+ return False
+
+ session = self._pending_sessions.pop(temp_id)
+ self._sessions[real_session_id] = session
+ self._temp_to_real[temp_id] = real_session_id
+ self._real_to_temp[real_session_id] = temp_id
+
+ logger.info(f"Registered session: {temp_id} -> {real_session_id}")
+ return True
+
+ async def remove_session(self, session_id: str) -> bool:
+ """Remove a session from the manager."""
+ async with self._lock:
+ if session_id in self._pending_sessions:
+ session = self._pending_sessions.pop(session_id)
+ await session.stop()
+ return True
+
+ if session_id in self._sessions:
+ session = self._sessions.pop(session_id)
+ await session.stop()
+ temp_id = self._real_to_temp.pop(session_id, None)
+ if temp_id is not None:
+ self._temp_to_real.pop(temp_id, None)
+ return True
+
+ return False
+
+ async def stop_all(self):
+ """Stop all sessions."""
+ async with self._lock:
+ all_sessions = list(self._sessions.values()) + list(
+ self._pending_sessions.values()
+ )
+ for session in all_sessions:
+ try:
+ await session.stop()
+ except Exception as e:
+ logger.error(f"Error stopping session: {e}")
+
+ self._sessions.clear()
+ self._pending_sessions.clear()
+ self._temp_to_real.clear()
+ self._real_to_temp.clear()
+ logger.info("All sessions stopped")
+
+ def get_stats(self) -> dict:
+ """Get session statistics."""
+ return {
+ "active_sessions": len(self._sessions),
+ "pending_sessions": len(self._pending_sessions),
+ "busy_count": sum(1 for s in self._sessions.values() if s.is_busy),
+ }
diff --git a/Claude_Code/cli/process_registry.py b/Claude_Code/cli/process_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..1dadda0f323144ecc83e4643125af922cc63091a
--- /dev/null
+++ b/Claude_Code/cli/process_registry.py
@@ -0,0 +1,74 @@
+"""Track and clean up spawned CLI subprocesses.
+
+This is a safety net for cases where the server is interrupted (Ctrl+C) and the
+FastAPI lifespan cleanup doesn't run to completion. We only track processes we
+spawn so we don't accidentally kill unrelated system processes.
+"""
+
+from __future__ import annotations
+
+import atexit
+import os
+import subprocess
+import threading
+
+from loguru import logger
+
+_lock = threading.Lock()
+_pids: set[int] = set()
+_atexit_registered = False
+
+
+def ensure_atexit_registered() -> None:
+ global _atexit_registered
+ with _lock:
+ if _atexit_registered:
+ return
+ atexit.register(kill_all_best_effort)
+ _atexit_registered = True
+
+
+def register_pid(pid: int) -> None:
+ if not pid:
+ return
+ ensure_atexit_registered()
+ with _lock:
+ _pids.add(int(pid))
+
+
+def unregister_pid(pid: int) -> None:
+ if not pid:
+ return
+ with _lock:
+ _pids.discard(int(pid))
+
+
+def kill_all_best_effort() -> None:
+ """Kill any still-running registered pids (best-effort)."""
+ with _lock:
+ pids = list(_pids)
+ _pids.clear()
+
+ if not pids:
+ return
+
+ if os.name == "nt":
+ for pid in pids:
+ try:
+ # /T kills child processes, /F forces termination.
+ subprocess.run(
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ )
+ except Exception as e:
+ logger.debug("process_registry: taskkill failed pid=%s: %s", pid, e)
+ return
+
+ # Best-effort fallback for non-Windows.
+ for pid in pids:
+ try:
+ os.kill(pid, 9)
+ except Exception as e:
+ logger.debug("process_registry: kill failed pid=%s: %s", pid, e)
diff --git a/Claude_Code/cli/session.py b/Claude_Code/cli/session.py
new file mode 100644
index 0000000000000000000000000000000000000000..88474141a38ac6e05ca29d320626c58b84ebf1e6
--- /dev/null
+++ b/Claude_Code/cli/session.py
@@ -0,0 +1,257 @@
+"""Claude Code CLI session management."""
+
+import asyncio
+import json
+import os
+from collections.abc import AsyncGenerator
+from typing import Any
+
+from loguru import logger
+
+from .process_registry import register_pid, unregister_pid
+
+
+class CLISession:
+ """Manages a single persistent Claude Code CLI subprocess."""
+
+ def __init__(
+ self,
+ workspace_path: str,
+ api_url: str,
+ allowed_dirs: list[str] | None = None,
+ plans_directory: str | None = None,
+ ):
+ self.workspace = os.path.normpath(os.path.abspath(workspace_path))
+ self.api_url = api_url
+ self.allowed_dirs = [os.path.normpath(d) for d in (allowed_dirs or [])]
+ self.plans_directory = plans_directory
+ self.process: asyncio.subprocess.Process | None = None
+ self.current_session_id: str | None = None
+ self._is_busy = False
+ self._cli_lock = asyncio.Lock()
+
+ @property
+ def is_busy(self) -> bool:
+ """Check if a task is currently running."""
+ return self._is_busy
+
+ async def start_task(
+ self, prompt: str, session_id: str | None = None, fork_session: bool = False
+ ) -> AsyncGenerator[dict]:
+ """
+ Start a new task or continue an existing session.
+
+ Args:
+ prompt: The user's message/prompt
+ session_id: Optional session ID to resume
+
+ Yields:
+ Event dictionaries from the CLI
+ """
+ async with self._cli_lock:
+ self._is_busy = True
+ env = os.environ.copy()
+
+ if "ANTHROPIC_API_KEY" not in env:
+ env["ANTHROPIC_API_KEY"] = "sk-placeholder-key-for-proxy"
+
+ env["ANTHROPIC_API_URL"] = self.api_url
+ if self.api_url.endswith("/v1"):
+ env["ANTHROPIC_BASE_URL"] = self.api_url[:-3]
+ else:
+ env["ANTHROPIC_BASE_URL"] = self.api_url
+
+ env["TERM"] = "dumb"
+ env["PYTHONIOENCODING"] = "utf-8"
+
+ # Build command
+ if session_id and not session_id.startswith("pending_"):
+ cmd = [
+ "claude",
+ "--resume",
+ session_id,
+ ]
+ if fork_session:
+ cmd.append("--fork-session")
+ cmd += [
+ "-p",
+ prompt,
+ "--output-format",
+ "stream-json",
+ "--dangerously-skip-permissions",
+ "--verbose",
+ ]
+ logger.info(f"Resuming Claude session {session_id}")
+ else:
+ cmd = [
+ "claude",
+ "-p",
+ prompt,
+ "--output-format",
+ "stream-json",
+ "--dangerously-skip-permissions",
+ "--verbose",
+ ]
+ logger.info("Starting new Claude session")
+
+ if self.allowed_dirs:
+ for d in self.allowed_dirs:
+ cmd.extend(["--add-dir", d])
+
+ if self.plans_directory is not None:
+ settings_json = json.dumps({"plansDirectory": self.plans_directory})
+ cmd.extend(["--settings", settings_json])
+
+ try:
+ self.process = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=self.workspace,
+ env=env,
+ )
+ if self.process and self.process.pid:
+ register_pid(self.process.pid)
+
+ if not self.process or not self.process.stdout:
+ yield {"type": "exit", "code": 1}
+ return
+
+ session_id_extracted = False
+ buffer = bytearray()
+
+ try:
+ while True:
+ chunk = await self.process.stdout.read(65536)
+ if not chunk:
+ if buffer:
+ line_str = buffer.decode(
+ "utf-8", errors="replace"
+ ).strip()
+ if line_str:
+ async for event in self._handle_line_gen(
+ line_str, session_id_extracted
+ ):
+ if event.get("type") == "session_info":
+ session_id_extracted = True
+ yield event
+ break
+
+ buffer.extend(chunk)
+
+ while True:
+ newline_pos = buffer.find(b"\n")
+ if newline_pos == -1:
+ break
+
+ line = buffer[:newline_pos]
+ buffer = buffer[newline_pos + 1 :]
+
+ line_str = line.decode("utf-8", errors="replace").strip()
+ if line_str:
+ async for event in self._handle_line_gen(
+ line_str, session_id_extracted
+ ):
+ if event.get("type") == "session_info":
+ session_id_extracted = True
+ yield event
+ except asyncio.CancelledError:
+ # Cancelling the handler task should not leave a Claude CLI
+ # subprocess running in the background.
+ try:
+ await asyncio.shield(self.stop())
+ finally:
+ raise
+
+ stderr_text = None
+ if self.process.stderr:
+ stderr_output = await self.process.stderr.read()
+ if stderr_output:
+ stderr_text = stderr_output.decode(
+ "utf-8", errors="replace"
+ ).strip()
+ logger.error(f"Claude CLI Stderr: {stderr_text}")
+ # Yield stderr as error event so it shows in UI
+ if stderr_text:
+ logger.info("CLI_SESSION: Yielding error event from stderr")
+ yield {"type": "error", "error": {"message": stderr_text}}
+
+ return_code = await self.process.wait()
+ logger.info(
+ f"Claude CLI exited with code {return_code}, stderr_present={bool(stderr_text)}"
+ )
+ if return_code != 0 and not stderr_text:
+ logger.warning(
+ f"CLI_SESSION: Process exited with code {return_code} but no stderr captured"
+ )
+ yield {
+ "type": "exit",
+ "code": return_code,
+ "stderr": stderr_text,
+ }
+ finally:
+ self._is_busy = False
+ if self.process and self.process.pid:
+ unregister_pid(self.process.pid)
+
+ async def _handle_line_gen(
+ self, line_str: str, session_id_extracted: bool
+ ) -> AsyncGenerator[dict]:
+ """Process a single line and yield events."""
+ try:
+ event = json.loads(line_str)
+ if not session_id_extracted:
+ extracted_id = self._extract_session_id(event)
+ if extracted_id:
+ self.current_session_id = extracted_id
+ logger.info(f"Extracted session ID: {extracted_id}")
+ yield {"type": "session_info", "session_id": extracted_id}
+
+ yield event
+ except json.JSONDecodeError:
+ logger.debug(f"Non-JSON output: {line_str}")
+ yield {"type": "raw", "content": line_str}
+
+ def _extract_session_id(self, event: Any) -> str | None:
+ """Extract session ID from CLI event."""
+ if not isinstance(event, dict):
+ return None
+
+ if "session_id" in event:
+ return event["session_id"]
+ if "sessionId" in event:
+ return event["sessionId"]
+
+ for key in ["init", "system", "result", "metadata"]:
+ if key in event and isinstance(event[key], dict):
+ nested = event[key]
+ if "session_id" in nested:
+ return nested["session_id"]
+ if "sessionId" in nested:
+ return nested["sessionId"]
+
+ if "conversation" in event and isinstance(event["conversation"], dict):
+ conv = event["conversation"]
+ if "id" in conv:
+ return conv["id"]
+
+ return None
+
+ async def stop(self):
+ """Stop the CLI process."""
+ if self.process and self.process.returncode is None:
+ try:
+ logger.info(f"Stopping Claude CLI process {self.process.pid}")
+ self.process.terminate()
+ try:
+ await asyncio.wait_for(self.process.wait(), timeout=5.0)
+ except TimeoutError:
+ self.process.kill()
+ await self.process.wait()
+ if self.process and self.process.pid:
+ unregister_pid(self.process.pid)
+ return True
+ except Exception as e:
+ logger.error(f"Error stopping process: {e}")
+ return False
+ return False
diff --git a/Claude_Code/config/__init__.py b/Claude_Code/config/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..87b0151f3a49b1bb8d4ac1a0c2c88b22e330000e
--- /dev/null
+++ b/Claude_Code/config/__init__.py
@@ -0,0 +1,5 @@
+"""Configuration management."""
+
+from .settings import Settings, get_settings
+
+__all__ = ["Settings", "get_settings"]
diff --git a/Claude_Code/config/env.example b/Claude_Code/config/env.example
new file mode 100644
index 0000000000000000000000000000000000000000..8965e3675f37d29c5a842b26e666d4f577276b78
--- /dev/null
+++ b/Claude_Code/config/env.example
@@ -0,0 +1,71 @@
+# NVIDIA NIM Config
+NVIDIA_NIM_API_KEY=""
+
+
+# OpenRouter Config
+OPENROUTER_API_KEY=""
+
+
+# LM Studio Config (local provider, no API key required)
+LM_STUDIO_BASE_URL="http://localhost:1234/v1"
+
+
+# All Claude model requests are mapped to these models, plain model is fallback
+# Format: provider_type/model/name
+# Valid providers: "nvidia_nim" | "open_router" | "lmstudio"
+MODEL_OPUS="nvidia_nim/z-ai/glm4.7"
+MODEL_SONNET="open_router/arcee-ai/trinity-large-preview:free"
+MODEL_HAIKU="open_router/stepfun/step-3.5-flash:free"
+MODEL="nvidia_nim/z-ai/glm4.7"
+
+
+# Provider config
+PROVIDER_RATE_LIMIT=40
+PROVIDER_RATE_WINDOW=60
+PROVIDER_MAX_CONCURRENCY=5
+
+
+# HTTP client timeouts (seconds) for provider API requests
+HTTP_READ_TIMEOUT=120
+HTTP_WRITE_TIMEOUT=10
+HTTP_CONNECT_TIMEOUT=2
+
+
+# Messaging Platform: "telegram" | "discord"
+MESSAGING_PLATFORM="discord"
+MESSAGING_RATE_LIMIT=1
+MESSAGING_RATE_WINDOW=1
+
+
+# Voice Note Transcription
+VOICE_NOTE_ENABLED=false
+# WHISPER_DEVICE: "cpu" | "cuda" | "nvidia_nim"
+# - "cpu"/"cuda": Hugging Face transformers Whisper (offline, free; install with: uv sync --extra voice_local)
+# - "nvidia_nim": NVIDIA NIM Whisper via Riva gRPC (requires NVIDIA_NIM_API_KEY; install with: uv sync --extra voice)
+WHISPER_DEVICE="nvidia_nim"
+# WHISPER_MODEL:
+# - For cpu/cuda: Hugging Face ID or short name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo)
+# - For nvidia_nim: NVIDIA NIM model (e.g., "nvidia/parakeet-ctc-1.1b-asr", "openai/whisper-large-v3")
+# - For nvidia_nim, default to "openai/whisper-large-v3" for best performance
+WHISPER_MODEL="openai/whisper-large-v3"
+HF_TOKEN=""
+
+
+# Telegram Config
+TELEGRAM_BOT_TOKEN=""
+ALLOWED_TELEGRAM_USER_ID=""
+
+
+# Discord Config
+DISCORD_BOT_TOKEN=""
+ALLOWED_DISCORD_CHANNELS=""
+
+
+# Agent Config
+CLAUDE_WORKSPACE="./agent_workspace"
+ALLOWED_DIR=""
+FAST_PREFIX_DETECTION=true
+ENABLE_NETWORK_PROBE_MOCK=true
+ENABLE_TITLE_GENERATION_SKIP=true
+ENABLE_SUGGESTION_MODE_SKIP=true
+ENABLE_FILEPATH_EXTRACTION_MOCK=true
\ No newline at end of file
diff --git a/Claude_Code/config/logging_config.py b/Claude_Code/config/logging_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1a31e4c5744178733e67879fa055120ca4524b3
--- /dev/null
+++ b/Claude_Code/config/logging_config.py
@@ -0,0 +1,90 @@
+"""Loguru-based structured logging configuration.
+
+All logs are written to server.log as JSON lines for full traceability.
+Stdlib logging is intercepted and funneled to loguru.
+Context vars (request_id, node_id, chat_id) from contextualize() are
+included at top level for easy grep/filter.
+"""
+
+import json
+import logging
+from pathlib import Path
+
+from loguru import logger
+
+_configured = False
+
+# Context keys we promote to top-level JSON for traceability
+_CONTEXT_KEYS = ("request_id", "node_id", "chat_id")
+
+
+def _serialize_with_context(record) -> str:
+ """Format record as JSON with context vars at top level.
+ Returns a format template; we inject _json into record for output.
+ """
+ extra = record.get("extra", {})
+ out = {
+ "time": str(record["time"]),
+ "level": record["level"].name,
+ "message": record["message"],
+ "module": record["name"],
+ "function": record["function"],
+ "line": record["line"],
+ }
+ for key in _CONTEXT_KEYS:
+ if key in extra and extra[key] is not None:
+ out[key] = extra[key]
+ record["_json"] = json.dumps(out, default=str)
+ return "{_json}\n"
+
+
+class InterceptHandler(logging.Handler):
+ """Redirect stdlib logging to loguru."""
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ level = logger.level(record.levelname).name
+ except ValueError:
+ level = record.levelno
+
+ frame, depth = logging.currentframe(), 2
+ while frame is not None and frame.f_code.co_filename == logging.__file__:
+ frame = frame.f_back
+ depth += 1
+
+ logger.opt(depth=depth, exception=record.exc_info).log(
+ level, record.getMessage()
+ )
+
+
+def configure_logging(log_file: str, *, force: bool = False) -> None:
+ """Configure loguru with JSON output to log_file and intercept stdlib logging.
+
+ Idempotent: skips if already configured (e.g. hot reload).
+ Use force=True to reconfigure (e.g. in tests with a different log path).
+ """
+ global _configured
+ if _configured and not force:
+ return
+ _configured = True
+
+ # Remove default loguru handler (writes to stderr)
+ logger.remove()
+
+ # Truncate log file on fresh start for clean debugging
+ Path(log_file).write_text("")
+
+ # Add file sink: JSON lines, DEBUG level, context vars at top level
+ logger.add(
+ log_file,
+ level="DEBUG",
+ format=_serialize_with_context,
+ encoding="utf-8",
+ mode="a",
+ rotation="50 MB",
+ )
+
+ # Intercept stdlib logging: route all root logger output to loguru
+ intercept = InterceptHandler()
+ logging.root.handlers = [intercept]
+ logging.root.setLevel(logging.DEBUG)
diff --git a/Claude_Code/config/nim.py b/Claude_Code/config/nim.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea2f9f9439a2ee33183a07860abddfdea696f8ba
--- /dev/null
+++ b/Claude_Code/config/nim.py
@@ -0,0 +1,51 @@
+"""NVIDIA NIM settings (fixed values, no env config)."""
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+class NimSettings(BaseModel):
+ """Fixed NVIDIA NIM settings (not configurable via env)."""
+
+ temperature: float = Field(1.0, ge=0.0)
+ top_p: float = Field(1.0, ge=0.0, le=1.0)
+ top_k: int = -1
+ max_tokens: int = Field(81920, ge=1)
+ presence_penalty: float = Field(0.0, ge=-2.0, le=2.0)
+ frequency_penalty: float = Field(0.0, ge=-2.0, le=2.0)
+
+ min_p: float = Field(0.0, ge=0.0, le=1.0)
+ repetition_penalty: float = Field(1.0, ge=0.0)
+
+ seed: int | None = None
+ stop: str | None = None
+
+ parallel_tool_calls: bool = True
+ ignore_eos: bool = False
+ enable_thinking: bool = False
+
+ min_tokens: int = Field(0, ge=0)
+ chat_template: str | None = None
+ request_id: str | None = None
+
+ model_config = ConfigDict(extra="forbid")
+
+ @field_validator("top_k")
+ @classmethod
+ def validate_top_k(cls, v):
+ if v < -1:
+ raise ValueError("top_k must be -1 or >= 0")
+ return v
+
+ @field_validator("seed", mode="before")
+ @classmethod
+ def parse_optional_int(cls, v):
+ if v == "" or v is None:
+ return None
+ return int(v)
+
+ @field_validator("stop", "chat_template", "request_id", mode="before")
+ @classmethod
+ def parse_optional_str(cls, v):
+ if v == "":
+ return None
+ return v
diff --git a/Claude_Code/config/settings.py b/Claude_Code/config/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1ca49ccf159e068b886e6e4b8b1a277610d9433
--- /dev/null
+++ b/Claude_Code/config/settings.py
@@ -0,0 +1,242 @@
+"""Centralized configuration using Pydantic Settings."""
+
+import os
+from functools import lru_cache
+from pathlib import Path
+
+from pydantic import Field, field_validator, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from .nim import NimSettings
+
+
+def _env_files() -> tuple[Path, ...]:
+ """Return env file paths in priority order (later overrides earlier)."""
+ files: list[Path] = [
+ Path.home() / ".config" / "free-claude-code" / ".env",
+ Path(".env"),
+ ]
+ if explicit := os.environ.get("FCC_ENV_FILE"):
+ files.append(Path(explicit))
+ return tuple(files)
+
+
+class Settings(BaseSettings):
+ """Application settings loaded from environment variables."""
+
+ # ==================== OpenRouter Config ====================
+ open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY")
+
+ # ==================== Messaging Platform Selection ====================
+ # Valid: "telegram" | "discord"
+ messaging_platform: str = Field(
+ default="discord", validation_alias="MESSAGING_PLATFORM"
+ )
+
+ # ==================== NVIDIA NIM Config ====================
+ nvidia_nim_api_key: str = ""
+
+ # ==================== LM Studio Config ====================
+ lm_studio_base_url: str = Field(
+ default="http://localhost:1234/v1",
+ validation_alias="LM_STUDIO_BASE_URL",
+ )
+
+ # ==================== Llama.cpp Config ====================
+ llamacpp_base_url: str = Field(
+ default="http://localhost:8080/v1",
+ validation_alias="LLAMACPP_BASE_URL",
+ )
+
+ # ==================== Model ====================
+ # All Claude model requests are mapped to this single model (fallback)
+ # Format: provider_type/model/name
+ model: str = "nvidia_nim/meta/llama3-70b-instruct"
+
+ # Per-model overrides (optional, falls back to MODEL)
+ # Each can use a different provider
+ model_opus: str | None = Field(default=None, validation_alias="MODEL_OPUS")
+ model_sonnet: str | None = Field(default=None, validation_alias="MODEL_SONNET")
+ model_haiku: str | None = Field(default=None, validation_alias="MODEL_HAIKU")
+
+ # ==================== Provider Rate Limiting ====================
+ provider_rate_limit: int = Field(default=40, validation_alias="PROVIDER_RATE_LIMIT")
+ provider_rate_window: int = Field(
+ default=60, validation_alias="PROVIDER_RATE_WINDOW"
+ )
+ provider_max_concurrency: int = Field(
+ default=5, validation_alias="PROVIDER_MAX_CONCURRENCY"
+ )
+
+ # ==================== HTTP Client Timeouts ====================
+ http_read_timeout: float = Field(
+ default=300.0, validation_alias="HTTP_READ_TIMEOUT"
+ )
+ http_write_timeout: float = Field(
+ default=10.0, validation_alias="HTTP_WRITE_TIMEOUT"
+ )
+ http_connect_timeout: float = Field(
+ default=2.0, validation_alias="HTTP_CONNECT_TIMEOUT"
+ )
+
+ # ==================== Fast Prefix Detection ====================
+ fast_prefix_detection: bool = True
+
+ # ==================== Optimizations ====================
+ enable_network_probe_mock: bool = True
+ enable_title_generation_skip: bool = True
+ enable_suggestion_mode_skip: bool = True
+ enable_filepath_extraction_mock: bool = True
+
+ # ==================== NIM Settings ====================
+ nim: NimSettings = Field(default_factory=NimSettings)
+ nim_enable_thinking: bool = Field(
+ default=False, validation_alias="NIM_ENABLE_THINKING"
+ )
+
+ # ==================== Voice Note Transcription ====================
+ voice_note_enabled: bool = Field(
+ default=True, validation_alias="VOICE_NOTE_ENABLED"
+ )
+ # Device: "cpu" | "cuda" | "nvidia_nim"
+ # - "cpu"/"cuda": local Whisper (requires voice_local extra: uv sync --extra voice_local)
+ # - "nvidia_nim": NVIDIA NIM Whisper API (requires voice extra: uv sync --extra voice)
+ whisper_device: str = Field(default="cpu", validation_alias="WHISPER_DEVICE")
+ # Whisper model ID or short name (for local Whisper) or NVIDIA NIM model (for nvidia_nim)
+ # Local Whisper: "tiny", "base", "small", "medium", "large-v2", "large-v3", "large-v3-turbo"
+ # NVIDIA NIM: "nvidia/parakeet-ctc-1.1b-asr", "openai/whisper-large-v3", etc.
+ whisper_model: str = Field(default="base", validation_alias="WHISPER_MODEL")
+ # Hugging Face token for faster model downloads (optional, for local Whisper)
+ hf_token: str = Field(default="", validation_alias="HF_TOKEN")
+
+ # ==================== Bot Wrapper Config ====================
+ telegram_bot_token: str | None = None
+ allowed_telegram_user_id: str | None = None
+ discord_bot_token: str | None = Field(
+ default=None, validation_alias="DISCORD_BOT_TOKEN"
+ )
+ allowed_discord_channels: str | None = Field(
+ default=None, validation_alias="ALLOWED_DISCORD_CHANNELS"
+ )
+ claude_workspace: str = "./agent_workspace"
+ allowed_dir: str = ""
+
+ # ==================== Server ====================
+ host: str = "0.0.0.0"
+ port: int = 8082
+ log_file: str = "server.log"
+ # Optional server API key to protect endpoints (Anthropic-style)
+ # Set via env `ANTHROPIC_AUTH_TOKEN`. When empty, no auth is required.
+ anthropic_auth_token: str = Field(
+ default="", validation_alias="ANTHROPIC_AUTH_TOKEN"
+ )
+
+ # Handle empty strings for optional string fields
+ @field_validator(
+ "telegram_bot_token",
+ "allowed_telegram_user_id",
+ "discord_bot_token",
+ "allowed_discord_channels",
+ mode="before",
+ )
+ @classmethod
+ def parse_optional_str(cls, v):
+ if v == "":
+ return None
+ return v
+
+ @field_validator("whisper_device")
+ @classmethod
+ def validate_whisper_device(cls, v: str) -> str:
+ if v not in ("cpu", "cuda", "nvidia_nim"):
+ raise ValueError(
+ f"whisper_device must be 'cpu', 'cuda', or 'nvidia_nim', got {v!r}"
+ )
+ return v
+
+ @field_validator("model", "model_opus", "model_sonnet", "model_haiku")
+ @classmethod
+ def validate_model_format(cls, v: str | None) -> str | None:
+ if v is None:
+ return None
+ valid_providers = ("nvidia_nim", "open_router", "lmstudio", "llamacpp")
+ if "/" not in v:
+ raise ValueError(
+ f"Model must be prefixed with provider type. "
+ f"Valid providers: {', '.join(valid_providers)}. "
+ f"Format: provider_type/model/name"
+ )
+ provider = v.split("/", 1)[0]
+ if provider not in valid_providers:
+ raise ValueError(
+ f"Invalid provider: '{provider}'. "
+ f"Supported: 'nvidia_nim', 'open_router', 'lmstudio', 'llamacpp'"
+ )
+ return v
+
+ @model_validator(mode="after")
+ def _inject_nim_thinking(self) -> "Settings":
+ self.nim = self.nim.model_copy(
+ update={"enable_thinking": self.nim_enable_thinking}
+ )
+ return self
+
+ @model_validator(mode="after")
+ def check_nvidia_nim_api_key(self) -> "Settings":
+ if (
+ self.voice_note_enabled
+ and self.whisper_device == "nvidia_nim"
+ and not self.nvidia_nim_api_key.strip()
+ ):
+ raise ValueError(
+ "NVIDIA_NIM_API_KEY is required when WHISPER_DEVICE is 'nvidia_nim'. "
+ "Set it in your .env file."
+ )
+ return self
+
+ @property
+ def provider_type(self) -> str:
+ """Extract provider type from the default model string."""
+ return self.model.split("/", 1)[0]
+
+ @property
+ def model_name(self) -> str:
+ """Extract the actual model name from the default model string."""
+ return self.model.split("/", 1)[1]
+
+ def resolve_model(self, claude_model_name: str) -> str:
+ """Resolve a Claude model name to the configured provider/model string.
+
+ Classifies the incoming Claude model (opus/sonnet/haiku) and
+ returns the model-specific override if configured, otherwise the fallback MODEL.
+ """
+ name_lower = claude_model_name.lower()
+ if "opus" in name_lower and self.model_opus is not None:
+ return self.model_opus
+ if "haiku" in name_lower and self.model_haiku is not None:
+ return self.model_haiku
+ if "sonnet" in name_lower and self.model_sonnet is not None:
+ return self.model_sonnet
+ return self.model
+
+ @staticmethod
+ def parse_provider_type(model_string: str) -> str:
+ """Extract provider type from any 'provider/model' string."""
+ return model_string.split("/", 1)[0]
+
+ @staticmethod
+ def parse_model_name(model_string: str) -> str:
+ """Extract model name from any 'provider/model' string."""
+ return model_string.split("/", 1)[1]
+
+ model_config = SettingsConfigDict(
+ env_file=_env_files(),
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+
+@lru_cache
+def get_settings() -> Settings:
+ """Get cached settings instance."""
+ return Settings()
diff --git a/Claude_Code/messaging/__init__.py b/Claude_Code/messaging/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4aaa736ef6a66c1e0c7114d51ea630dc77754408
--- /dev/null
+++ b/Claude_Code/messaging/__init__.py
@@ -0,0 +1,23 @@
+"""Platform-agnostic messaging layer."""
+
+from .event_parser import parse_cli_event
+from .handler import ClaudeMessageHandler
+from .models import IncomingMessage
+from .platforms.base import CLISession, MessagingPlatform, SessionManagerInterface
+from .session import SessionStore
+from .trees.data import MessageNode, MessageState, MessageTree
+from .trees.queue_manager import TreeQueueManager
+
+__all__ = [
+ "CLISession",
+ "ClaudeMessageHandler",
+ "IncomingMessage",
+ "MessageNode",
+ "MessageState",
+ "MessageTree",
+ "MessagingPlatform",
+ "SessionManagerInterface",
+ "SessionStore",
+ "TreeQueueManager",
+ "parse_cli_event",
+]
diff --git a/Claude_Code/messaging/commands.py b/Claude_Code/messaging/commands.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a16ef50c55e101a2b2daf13144a515d0bb621f6
--- /dev/null
+++ b/Claude_Code/messaging/commands.py
@@ -0,0 +1,283 @@
+"""Command handlers for messaging platform commands (/stop, /stats, /clear).
+
+Extracted from ClaudeMessageHandler to keep handler.py focused on
+core message processing logic.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+if TYPE_CHECKING:
+ from messaging.handler import ClaudeMessageHandler
+ from messaging.models import IncomingMessage
+
+
+async def handle_stop_command(
+ handler: ClaudeMessageHandler, incoming: IncomingMessage
+) -> None:
+ """Handle /stop command from messaging platform."""
+ # Reply-scoped stop: reply "/stop" to stop only that task.
+ if incoming.is_reply() and incoming.reply_to_message_id:
+ reply_id = incoming.reply_to_message_id
+ tree = handler.tree_queue.get_tree_for_node(reply_id)
+ node_id = handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
+
+ if not node_id:
+ msg_id = await handler.platform.queue_send_message(
+ incoming.chat_id,
+ handler.format_status(
+ "⏹", "Stopped.", "Nothing to stop for that message."
+ ),
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ handler.record_outgoing_message(
+ incoming.platform, incoming.chat_id, msg_id, "command"
+ )
+ return
+
+ count = await handler.stop_task(node_id)
+ noun = "request" if count == 1 else "requests"
+ msg_id = await handler.platform.queue_send_message(
+ incoming.chat_id,
+ handler.format_status("⏹", "Stopped.", f"Cancelled {count} {noun}."),
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ handler.record_outgoing_message(
+ incoming.platform, incoming.chat_id, msg_id, "command"
+ )
+ return
+
+ # Global stop: legacy behavior (stop everything)
+ count = await handler.stop_all_tasks()
+ msg_id = await handler.platform.queue_send_message(
+ incoming.chat_id,
+ handler.format_status(
+ "⏹", "Stopped.", f"Cancelled {count} pending or active requests."
+ ),
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ handler.record_outgoing_message(
+ incoming.platform, incoming.chat_id, msg_id, "command"
+ )
+
+
+async def handle_stats_command(
+ handler: ClaudeMessageHandler, incoming: IncomingMessage
+) -> None:
+ """Handle /stats command."""
+ stats = handler.cli_manager.get_stats()
+ tree_count = handler.tree_queue.get_tree_count()
+ ctx = handler.get_render_ctx()
+ msg_id = await handler.platform.queue_send_message(
+ incoming.chat_id,
+ "📊 "
+ + ctx.bold("Stats")
+ + "\n"
+ + ctx.escape_text(f"• Active CLI: {stats['active_sessions']}")
+ + "\n"
+ + ctx.escape_text(f"• Message Trees: {tree_count}"),
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ handler.record_outgoing_message(
+ incoming.platform, incoming.chat_id, msg_id, "command"
+ )
+
+
+async def _delete_message_ids(
+ handler: ClaudeMessageHandler, chat_id: str, msg_ids: set[str]
+) -> None:
+ """Best-effort delete messages by ID. Sorts numeric IDs descending."""
+ if not msg_ids:
+ return
+
+ def _as_int(s: str) -> int | None:
+ try:
+ return int(str(s))
+ except Exception:
+ return None
+
+ numeric: list[tuple[int, str]] = []
+ non_numeric: list[str] = []
+ for mid in msg_ids:
+ n = _as_int(mid)
+ if n is None:
+ non_numeric.append(mid)
+ else:
+ numeric.append((n, mid))
+ numeric.sort(reverse=True)
+ ordered = [mid for _, mid in numeric] + non_numeric
+
+ batch_fn = getattr(handler.platform, "queue_delete_messages", None)
+ if callable(batch_fn):
+ try:
+ CHUNK = 100
+ for i in range(0, len(ordered), CHUNK):
+ chunk = ordered[i : i + CHUNK]
+ await batch_fn(chat_id, chunk, fire_and_forget=False)
+ except Exception as e:
+ logger.debug(f"Batch delete failed: {type(e).__name__}: {e}")
+ else:
+ for mid in ordered:
+ try:
+ await handler.platform.queue_delete_message(
+ chat_id, mid, fire_and_forget=False
+ )
+ except Exception as e:
+ logger.debug(f"Delete failed for msg {mid}: {type(e).__name__}: {e}")
+
+
+async def _handle_clear_branch(
+ handler: ClaudeMessageHandler,
+ incoming: IncomingMessage,
+ branch_root_id: str,
+) -> None:
+ """
+ Clear a branch (replied-to node + all descendants).
+
+ Order: cancel tasks, delete messages, remove branch, update session store.
+ """
+ tree = handler.tree_queue.get_tree_for_node(branch_root_id)
+ if not tree:
+ return
+
+ # 1) Cancel branch tasks (no stop_all)
+ cancelled = await handler.tree_queue.cancel_branch(branch_root_id)
+ handler.update_cancelled_nodes_ui(cancelled)
+
+ # 2) Collect message IDs from branch nodes only
+ msg_ids: set[str] = set()
+ branch_ids = tree.get_descendants(branch_root_id)
+ for nid in branch_ids:
+ node = tree.get_node(nid)
+ if node:
+ if node.incoming.message_id:
+ msg_ids.add(str(node.incoming.message_id))
+ if node.status_message_id:
+ msg_ids.add(str(node.status_message_id))
+ if incoming.message_id:
+ msg_ids.add(str(incoming.message_id))
+
+ # 3) Delete messages (best-effort)
+ await _delete_message_ids(handler, incoming.chat_id, msg_ids)
+
+ # 4) Remove branch from tree
+ removed, root_id, removed_entire_tree = await handler.tree_queue.remove_branch(
+ branch_root_id
+ )
+
+ # 5) Update session store
+ try:
+ handler.session_store.remove_node_mappings([n.node_id for n in removed])
+ if removed_entire_tree:
+ handler.session_store.remove_tree(root_id)
+ else:
+ updated_tree = handler.tree_queue.get_tree(root_id)
+ if updated_tree:
+ handler.session_store.save_tree(root_id, updated_tree.to_dict())
+ except Exception as e:
+ logger.warning(f"Failed to update session store after branch clear: {e}")
+
+
+async def handle_clear_command(
+ handler: ClaudeMessageHandler, incoming: IncomingMessage
+) -> None:
+ """
+ Handle /clear command.
+
+ Reply-scoped: reply to a message to clear that branch (node + descendants).
+ Standalone: global clear (stop all, delete all chat messages, reset store).
+ """
+ from messaging.trees import TreeQueueManager
+
+ if incoming.is_reply() and incoming.reply_to_message_id:
+ reply_id = incoming.reply_to_message_id
+ tree = handler.tree_queue.get_tree_for_node(reply_id)
+ branch_root_id = (
+ handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
+ )
+ if not branch_root_id:
+ cancel_fn = getattr(handler.platform, "cancel_pending_voice", None)
+ if cancel_fn is not None:
+ cancelled = await cancel_fn(incoming.chat_id, reply_id)
+ if cancelled is not None:
+ voice_msg_id, status_msg_id = cancelled
+ msg_ids_to_del: set[str] = {voice_msg_id, status_msg_id}
+ if incoming.message_id is not None:
+ msg_ids_to_del.add(str(incoming.message_id))
+ await _delete_message_ids(handler, incoming.chat_id, msg_ids_to_del)
+ msg_id = await handler.platform.queue_send_message(
+ incoming.chat_id,
+ handler.format_status("🗑", "Cleared.", "Voice note cancelled."),
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ handler.record_outgoing_message(
+ incoming.platform, incoming.chat_id, msg_id, "command"
+ )
+ return
+ msg_id = await handler.platform.queue_send_message(
+ incoming.chat_id,
+ handler.format_status(
+ "🗑", "Cleared.", "Nothing to clear for that message."
+ ),
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ handler.record_outgoing_message(
+ incoming.platform, incoming.chat_id, msg_id, "command"
+ )
+ return
+ await _handle_clear_branch(handler, incoming, branch_root_id)
+ return
+
+ # Global clear
+ # 1) Stop tasks first (ensures no more work is running).
+ await handler.stop_all_tasks()
+
+ # 2) Clear chat: best-effort delete messages we can identify.
+ msg_ids: set[str] = set()
+
+ # Add any recorded message IDs for this chat (commands, command replies, etc).
+ try:
+ for mid in handler.session_store.get_message_ids_for_chat(
+ incoming.platform, incoming.chat_id
+ ):
+ if mid is not None:
+ msg_ids.add(str(mid))
+ except Exception as e:
+ logger.debug(f"Failed to read message log for /clear: {e}")
+
+ try:
+ msg_ids.update(
+ handler.tree_queue.get_message_ids_for_chat(
+ incoming.platform, incoming.chat_id
+ )
+ )
+ except Exception as e:
+ logger.warning(f"Failed to gather messages for /clear: {e}")
+
+ # Also delete the command message itself.
+ if incoming.message_id is not None:
+ msg_ids.add(str(incoming.message_id))
+
+ await _delete_message_ids(handler, incoming.chat_id, msg_ids)
+
+ # 3) Clear persistent state and reset in-memory queue/tree state.
+ try:
+ handler.session_store.clear_all()
+ except Exception as e:
+ logger.warning(f"Failed to clear session store: {e}")
+
+ handler.replace_tree_queue(
+ TreeQueueManager(
+ queue_update_callback=handler.update_queue_positions,
+ node_started_callback=handler.mark_node_processing,
+ )
+ )
diff --git a/Claude_Code/messaging/event_parser.py b/Claude_Code/messaging/event_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..24992484e5af8a42bbf496f23db46b8a7644cc37
--- /dev/null
+++ b/Claude_Code/messaging/event_parser.py
@@ -0,0 +1,163 @@
+"""CLI event parser for Claude Code CLI output.
+
+This parser emits an ordered stream of low-level events suitable for building a
+Claude Code-like transcript in messaging UIs.
+"""
+
+from typing import Any
+
+from loguru import logger
+
+
+def parse_cli_event(event: Any) -> list[dict]:
+ """
+ Parse a CLI event and return a structured result.
+
+ Args:
+ event: Raw event dictionary from CLI
+
+ Returns:
+ List of parsed event dicts. Empty list if not recognized.
+ """
+ if not isinstance(event, dict):
+ return []
+
+ etype = event.get("type")
+ results: list[dict[str, Any]] = []
+
+ # Some CLI/proxy layers emit "system" events that are not user-visible and
+ # carry no transcript content. Ignore them explicitly to avoid noisy logs.
+ if etype == "system":
+ return []
+
+ # 1. Handle full messages (assistant/user or result)
+ msg_obj = None
+ if etype == "assistant" or etype == "user":
+ msg_obj = event.get("message")
+ elif etype == "result":
+ res = event.get("result")
+ if isinstance(res, dict):
+ msg_obj = res.get("message")
+ # Some variants put content directly on the result.
+ if not msg_obj and isinstance(res.get("content"), list):
+ msg_obj = {"content": res.get("content")}
+ if not msg_obj:
+ msg_obj = event.get("message")
+ # Some variants put content directly on the event.
+ if not msg_obj and isinstance(event.get("content"), list):
+ msg_obj = {"content": event.get("content")}
+
+ if msg_obj and isinstance(msg_obj, dict):
+ content = msg_obj.get("content", [])
+ if isinstance(content, list):
+ # Preserve order exactly as content blocks appear.
+ for c in content:
+ if not isinstance(c, dict):
+ continue
+ ctype = c.get("type")
+ if ctype == "text":
+ results.append({"type": "text_chunk", "text": c.get("text", "")})
+ elif ctype == "thinking":
+ results.append(
+ {"type": "thinking_chunk", "text": c.get("thinking", "")}
+ )
+ elif ctype == "tool_use":
+ results.append(
+ {
+ "type": "tool_use",
+ "id": str(c.get("id", "") or "").strip(),
+ "name": c.get("name", ""),
+ "input": c.get("input"),
+ }
+ )
+ elif ctype == "tool_result":
+ results.append(
+ {
+ "type": "tool_result",
+ "tool_use_id": str(c.get("tool_use_id", "") or "").strip(),
+ "content": c.get("content"),
+ "is_error": bool(c.get("is_error", False)),
+ }
+ )
+
+ if results:
+ return results
+
+ # 2. Handle streaming deltas
+ if etype == "content_block_delta":
+ delta = event.get("delta", {})
+ if isinstance(delta, dict):
+ if delta.get("type") == "text_delta":
+ return [
+ {
+ "type": "text_delta",
+ "index": event.get("index", -1),
+ "text": delta.get("text", ""),
+ }
+ ]
+ if delta.get("type") == "thinking_delta":
+ return [
+ {
+ "type": "thinking_delta",
+ "index": event.get("index", -1),
+ "text": delta.get("thinking", ""),
+ }
+ ]
+ if delta.get("type") == "input_json_delta":
+ return [
+ {
+ "type": "tool_use_delta",
+ "index": event.get("index", -1),
+ "partial_json": delta.get("partial_json", ""),
+ }
+ ]
+
+ # 3. Handle tool usage start
+ if etype == "content_block_start":
+ block = event.get("content_block", {})
+ if isinstance(block, dict):
+ btype = block.get("type")
+ if btype == "thinking":
+ return [{"type": "thinking_start", "index": event.get("index", -1)}]
+ if btype == "text":
+ return [{"type": "text_start", "index": event.get("index", -1)}]
+ if btype == "tool_use":
+ return [
+ {
+ "type": "tool_use_start",
+ "index": event.get("index", -1),
+ "id": str(block.get("id", "") or "").strip(),
+ "name": block.get("name", ""),
+ "input": block.get("input"),
+ }
+ ]
+
+ # 3.5 Handle block stop (to close open streaming segments)
+ if etype == "content_block_stop":
+ return [{"type": "block_stop", "index": event.get("index", -1)}]
+
+ # 4. Handle errors and exit
+ if etype == "error":
+ err = event.get("error")
+ msg = err.get("message") if isinstance(err, dict) else str(err)
+ logger.info(f"CLI_PARSER: Parsed error event: {msg}")
+ return [{"type": "error", "message": msg}]
+ elif etype == "exit":
+ code = event.get("code", 0)
+ stderr = event.get("stderr")
+ if code == 0:
+ logger.debug(f"CLI_PARSER: Successful exit (code={code})")
+ return [{"type": "complete", "status": "success"}]
+ else:
+ # Non-zero exit is an error
+ error_msg = stderr if stderr else f"Process exited with code {code}"
+ logger.warning(f"CLI_PARSER: Error exit (code={code}): {error_msg}")
+ return [
+ {"type": "error", "message": error_msg},
+ {"type": "complete", "status": "failed"},
+ ]
+
+ # Log unrecognized events for debugging
+ if etype:
+ logger.debug(f"CLI_PARSER: Unrecognized event type: {etype}")
+ return []
diff --git a/Claude_Code/messaging/handler.py b/Claude_Code/messaging/handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..7eae77ee2ae75874453c2aed7340648bb061a363
--- /dev/null
+++ b/Claude_Code/messaging/handler.py
@@ -0,0 +1,770 @@
+"""
+Claude Message Handler
+
+Platform-agnostic Claude interaction logic.
+Handles the core workflow of processing user messages via Claude CLI.
+Uses tree-based queuing for message ordering.
+"""
+
+import asyncio
+import os
+import time
+
+from loguru import logger
+
+from providers.common import get_user_facing_error_message
+
+from .commands import (
+ handle_clear_command,
+ handle_stats_command,
+ handle_stop_command,
+)
+from .event_parser import parse_cli_event
+from .models import IncomingMessage
+from .platforms.base import MessagingPlatform, SessionManagerInterface
+from .rendering.discord_markdown import (
+ discord_bold,
+ discord_code_inline,
+ escape_discord,
+ escape_discord_code,
+ render_markdown_to_discord,
+)
+from .rendering.discord_markdown import (
+ format_status as format_status_discord, # (emoji, label, suffix)
+)
+from .rendering.telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+)
+from .rendering.telegram_markdown import (
+ format_status as format_status_telegram,
+)
+from .session import SessionStore
+from .transcript import RenderCtx, TranscriptBuffer
+from .trees.queue_manager import (
+ MessageNode,
+ MessageState,
+ MessageTree,
+ TreeQueueManager,
+)
+
+# Status message prefixes used to filter our own messages (ignore echo)
+STATUS_MESSAGE_PREFIXES = ("⏳", "💭", "🔧", "✅", "❌", "🚀", "🤖", "📋", "📊", "🔄")
+
+# Event types that update the transcript (frozenset for O(1) membership)
+TRANSCRIPT_EVENT_TYPES = frozenset(
+ {
+ "thinking_start",
+ "thinking_delta",
+ "thinking_chunk",
+ "thinking_stop",
+ "text_start",
+ "text_delta",
+ "text_chunk",
+ "text_stop",
+ "tool_use_start",
+ "tool_use_delta",
+ "tool_use_stop",
+ "tool_use",
+ "tool_result",
+ "block_stop",
+ "error",
+ }
+)
+
+# Event type -> (emoji, label) for status updates (O(1) lookup)
+_EVENT_STATUS_MAP = {
+ "thinking_start": ("🧠", "Claude is thinking..."),
+ "thinking_delta": ("🧠", "Claude is thinking..."),
+ "thinking_chunk": ("🧠", "Claude is thinking..."),
+ "text_start": ("🧠", "Claude is working..."),
+ "text_delta": ("🧠", "Claude is working..."),
+ "text_chunk": ("🧠", "Claude is working..."),
+ "tool_result": ("⏳", "Executing tools..."),
+}
+
+
+def _get_status_for_event(ptype: str, parsed: dict, format_status_fn) -> str | None:
+ """Return status string for event type, or None if no status update needed."""
+ entry = _EVENT_STATUS_MAP.get(ptype)
+ if entry is not None:
+ emoji, label = entry
+ return format_status_fn(emoji, label)
+ if ptype in ("tool_use_start", "tool_use_delta", "tool_use"):
+ if parsed.get("name") == "Task":
+ return format_status_fn("🤖", "Subagent working...")
+ return format_status_fn("⏳", "Executing tools...")
+ return None
+
+
+class ClaudeMessageHandler:
+ """
+ Platform-agnostic handler for Claude interactions.
+
+ Uses a tree-based message queue where:
+ - New messages create a tree root
+ - Replies become children of the message being replied to
+ - Each node has state: PENDING, IN_PROGRESS, COMPLETED, ERROR
+ - Per-tree queue ensures ordered processing
+ """
+
+ def __init__(
+ self,
+ platform: MessagingPlatform,
+ cli_manager: SessionManagerInterface,
+ session_store: SessionStore,
+ ):
+ self.platform = platform
+ self.cli_manager = cli_manager
+ self.session_store = session_store
+ self._tree_queue = TreeQueueManager(
+ queue_update_callback=self.update_queue_positions,
+ node_started_callback=self.mark_node_processing,
+ )
+ is_discord = platform.name == "discord"
+ self._format_status_fn = (
+ format_status_discord if is_discord else format_status_telegram
+ )
+ self._parse_mode_val: str | None = None if is_discord else "MarkdownV2"
+ self._render_ctx_val = RenderCtx(
+ bold=discord_bold if is_discord else mdv2_bold,
+ code_inline=discord_code_inline if is_discord else mdv2_code_inline,
+ escape_code=escape_discord_code if is_discord else escape_md_v2_code,
+ escape_text=escape_discord if is_discord else escape_md_v2,
+ render_markdown=render_markdown_to_discord
+ if is_discord
+ else render_markdown_to_mdv2,
+ )
+ self._limit_chars = 1900 if is_discord else 3900
+
+ def format_status(self, emoji: str, label: str, suffix: str | None = None) -> str:
+ return self._format_status_fn(emoji, label, suffix)
+
+ def _parse_mode(self) -> str | None:
+ return self._parse_mode_val
+
+ def get_render_ctx(self) -> RenderCtx:
+ return self._render_ctx_val
+
+ def _get_limit_chars(self) -> int:
+ return self._limit_chars
+
+ @property
+ def tree_queue(self) -> TreeQueueManager:
+ """Accessor for the current tree queue manager."""
+ return self._tree_queue
+
+ def replace_tree_queue(self, tree_queue: TreeQueueManager) -> None:
+ """Replace tree queue manager via explicit API."""
+ self._tree_queue = tree_queue
+ self._tree_queue.set_queue_update_callback(self.update_queue_positions)
+ self._tree_queue.set_node_started_callback(self.mark_node_processing)
+
+ async def handle_message(self, incoming: IncomingMessage) -> None:
+ """
+ Main entry point for handling an incoming message.
+
+ Determines if this is a new conversation or reply,
+ creates/extends the message tree, and queues for processing.
+ """
+ text_preview = (incoming.text or "")[:80]
+ if len(incoming.text or "") > 80:
+ text_preview += "..."
+ logger.info(
+ "HANDLER_ENTRY: chat_id={} message_id={} reply_to={} text_preview={!r}",
+ incoming.chat_id,
+ incoming.message_id,
+ incoming.reply_to_message_id,
+ text_preview,
+ )
+
+ with logger.contextualize(
+ chat_id=incoming.chat_id, node_id=incoming.message_id
+ ):
+ await self._handle_message_impl(incoming)
+
+ async def _handle_message_impl(self, incoming: IncomingMessage) -> None:
+ """Implementation of handle_message with context bound."""
+ # Check for commands
+ parts = (incoming.text or "").strip().split()
+ cmd = parts[0] if parts else ""
+ cmd_base = cmd.split("@", 1)[0] if cmd else ""
+
+ # Record incoming message ID for best-effort UI clearing (/clear), even if
+ # we later ignore this message (status/command/etc).
+ try:
+ if incoming.message_id is not None:
+ kind = "command" if cmd_base.startswith("/") else "content"
+ self.session_store.record_message_id(
+ incoming.platform,
+ incoming.chat_id,
+ str(incoming.message_id),
+ direction="in",
+ kind=kind,
+ )
+ except Exception as e:
+ logger.debug(f"Failed to record incoming message_id: {e}")
+
+ if cmd_base == "/clear":
+ await self._handle_clear_command(incoming)
+ return
+
+ if cmd_base == "/stop":
+ await self._handle_stop_command(incoming)
+ return
+
+ if cmd_base == "/stats":
+ await self._handle_stats_command(incoming)
+ return
+
+ # Filter out status messages (our own messages)
+ text = incoming.text or ""
+ if any(text.startswith(p) for p in STATUS_MESSAGE_PREFIXES):
+ return
+
+ # Check if this is a reply to an existing node in a tree
+ parent_node_id = None
+ tree = None
+
+ if incoming.is_reply() and incoming.reply_to_message_id:
+ # Look up if the replied-to message is in any tree (could be a node or status message)
+ reply_id = incoming.reply_to_message_id
+ tree = self.tree_queue.get_tree_for_node(reply_id)
+ if tree:
+ # Resolve to actual node ID (handles status message replies)
+ parent_node_id = self.tree_queue.resolve_parent_node_id(reply_id)
+ if parent_node_id:
+ logger.info(f"Found tree for reply, parent node: {parent_node_id}")
+ else:
+ logger.warning(
+ f"Reply to {incoming.reply_to_message_id} found tree but no valid parent node"
+ )
+ tree = None # Treat as new conversation
+
+ # Generate node ID
+ node_id = incoming.message_id
+
+ # Use pre-sent status (e.g. voice note) or send new
+ status_text = self._get_initial_status(tree, parent_node_id)
+ if incoming.status_message_id:
+ status_msg_id = incoming.status_message_id
+ await self.platform.queue_edit_message(
+ incoming.chat_id,
+ status_msg_id,
+ status_text,
+ parse_mode=self._parse_mode(),
+ fire_and_forget=False,
+ )
+ else:
+ status_msg_id = await self.platform.queue_send_message(
+ incoming.chat_id,
+ status_text,
+ reply_to=incoming.message_id,
+ fire_and_forget=False,
+ message_thread_id=incoming.message_thread_id,
+ )
+ self.record_outgoing_message(
+ incoming.platform, incoming.chat_id, status_msg_id, "status"
+ )
+
+ # Create or extend tree
+ if parent_node_id and tree and status_msg_id:
+ # Reply to existing node - add as child
+ tree, _node = await self.tree_queue.add_to_tree(
+ parent_node_id=parent_node_id,
+ node_id=node_id,
+ incoming=incoming,
+ status_message_id=status_msg_id,
+ )
+ # Register status message as a node too for reply chains
+ self.tree_queue.register_node(status_msg_id, tree.root_id)
+ self.session_store.register_node(status_msg_id, tree.root_id)
+ self.session_store.register_node(node_id, tree.root_id)
+ elif status_msg_id:
+ # New conversation - create new tree
+ tree = await self.tree_queue.create_tree(
+ node_id=node_id,
+ incoming=incoming,
+ status_message_id=status_msg_id,
+ )
+ # Register status message
+ self.tree_queue.register_node(status_msg_id, tree.root_id)
+ self.session_store.register_node(node_id, tree.root_id)
+ self.session_store.register_node(status_msg_id, tree.root_id)
+
+ # Persist tree
+ if tree:
+ self.session_store.save_tree(tree.root_id, tree.to_dict())
+
+ # Enqueue for processing
+ was_queued = await self.tree_queue.enqueue(
+ node_id=node_id,
+ processor=self._process_node,
+ )
+
+ if was_queued and status_msg_id:
+ # Update status to show queue position
+ queue_size = self.tree_queue.get_queue_size(node_id)
+ await self.platform.queue_edit_message(
+ incoming.chat_id,
+ status_msg_id,
+ self.format_status(
+ "📋", "Queued", f"(position {queue_size}) - waiting..."
+ ),
+ parse_mode=self._parse_mode(),
+ )
+
+ async def update_queue_positions(self, tree: MessageTree) -> None:
+ """Refresh queued status messages after a dequeue."""
+ try:
+ queued_ids = await tree.get_queue_snapshot()
+ except Exception as e:
+ logger.warning(f"Failed to read queue snapshot: {e}")
+ return
+
+ if not queued_ids:
+ return
+
+ position = 0
+ for node_id in queued_ids:
+ node = tree.get_node(node_id)
+ if not node or node.state != MessageState.PENDING:
+ continue
+ position += 1
+ self.platform.fire_and_forget(
+ self.platform.queue_edit_message(
+ node.incoming.chat_id,
+ node.status_message_id,
+ self.format_status(
+ "📋", "Queued", f"(position {position}) - waiting..."
+ ),
+ parse_mode=self._parse_mode(),
+ )
+ )
+
+ async def mark_node_processing(self, tree: MessageTree, node_id: str) -> None:
+ """Update the dequeued node's status to processing immediately."""
+ node = tree.get_node(node_id)
+ if not node or node.state == MessageState.ERROR:
+ return
+ self.platform.fire_and_forget(
+ self.platform.queue_edit_message(
+ node.incoming.chat_id,
+ node.status_message_id,
+ self.format_status("🔄", "Processing..."),
+ parse_mode=self._parse_mode(),
+ )
+ )
+
+ def _create_transcript_and_render_ctx(
+ self,
+ ) -> tuple[TranscriptBuffer, RenderCtx]:
+ """Create transcript buffer and render context for node processing."""
+ transcript = TranscriptBuffer(show_tool_results=False)
+ return transcript, self.get_render_ctx()
+
+ async def _handle_session_info_event(
+ self,
+ event_data: dict,
+ tree: MessageTree | None,
+ node_id: str,
+ captured_session_id: str | None,
+ temp_session_id: str | None,
+ ) -> tuple[str | None, str | None]:
+ """Handle session_info event; return updated (captured_session_id, temp_session_id)."""
+ if event_data.get("type") != "session_info":
+ return captured_session_id, temp_session_id
+
+ real_session_id = event_data.get("session_id")
+ if not real_session_id or not temp_session_id:
+ return captured_session_id, temp_session_id
+
+ await self.cli_manager.register_real_session_id(
+ temp_session_id, real_session_id
+ )
+ if tree and real_session_id:
+ await tree.update_state(
+ node_id,
+ MessageState.IN_PROGRESS,
+ session_id=real_session_id,
+ )
+ self.session_store.save_tree(tree.root_id, tree.to_dict())
+
+ return real_session_id, None
+
+ async def _process_parsed_event(
+ self,
+ parsed: dict,
+ transcript: TranscriptBuffer,
+ update_ui,
+ last_status: str | None,
+ had_transcript_events: bool,
+ tree: MessageTree | None,
+ node_id: str,
+ captured_session_id: str | None,
+ ) -> tuple[str | None, bool]:
+ """Process a single parsed CLI event. Returns (last_status, had_transcript_events)."""
+ ptype = parsed.get("type") or ""
+
+ if ptype in TRANSCRIPT_EVENT_TYPES:
+ transcript.apply(parsed)
+ had_transcript_events = True
+
+ status = _get_status_for_event(ptype, parsed, self.format_status)
+ if status is not None:
+ await update_ui(status)
+ last_status = status
+ elif ptype == "block_stop":
+ await update_ui(last_status, force=True)
+ elif ptype == "complete":
+ if not had_transcript_events:
+ transcript.apply({"type": "text_chunk", "text": "Done."})
+ logger.info("HANDLER: Task complete, updating UI")
+ await update_ui(self.format_status("✅", "Complete"), force=True)
+ if tree and captured_session_id:
+ await tree.update_state(
+ node_id,
+ MessageState.COMPLETED,
+ session_id=captured_session_id,
+ )
+ self.session_store.save_tree(tree.root_id, tree.to_dict())
+ elif ptype == "error":
+ error_msg = parsed.get("message", "Unknown error")
+ logger.error(f"HANDLER: Error event received: {error_msg}")
+ logger.info("HANDLER: Updating UI with error status")
+ await update_ui(self.format_status("❌", "Error"), force=True)
+ if tree:
+ await self._propagate_error_to_children(
+ node_id, error_msg, "Parent task failed"
+ )
+
+ return last_status, had_transcript_events
+
+ async def _process_node(
+ self,
+ node_id: str,
+ node: MessageNode,
+ ) -> None:
+ """Core task processor - handles a single Claude CLI interaction."""
+ incoming = node.incoming
+ status_msg_id = node.status_message_id
+ chat_id = incoming.chat_id
+
+ with logger.contextualize(node_id=node_id, chat_id=chat_id):
+ await self._process_node_impl(node_id, node, chat_id, status_msg_id)
+
+ async def _process_node_impl(
+ self,
+ node_id: str,
+ node: MessageNode,
+ chat_id: str,
+ status_msg_id: str,
+ ) -> None:
+ """Internal implementation of _process_node with context bound."""
+ incoming = node.incoming
+
+ tree = self.tree_queue.get_tree_for_node(node_id)
+ if tree:
+ await tree.update_state(node_id, MessageState.IN_PROGRESS)
+
+ transcript, render_ctx = self._create_transcript_and_render_ctx()
+
+ last_ui_update = 0.0
+ last_displayed_text = None
+ had_transcript_events = False
+ captured_session_id = None
+ temp_session_id = None
+ last_status: str | None = None
+
+ parent_session_id = None
+ if tree and node.parent_id:
+ parent_session_id = tree.get_parent_session_id(node_id)
+ if parent_session_id:
+ logger.info(f"Will fork from parent session: {parent_session_id}")
+
+ async def update_ui(status: str | None = None, force: bool = False) -> None:
+ nonlocal last_ui_update, last_displayed_text, last_status
+ now = time.time()
+ if not force and now - last_ui_update < 1.0:
+ return
+
+ last_ui_update = now
+ if status is not None:
+ last_status = status
+ try:
+ display = transcript.render(
+ render_ctx,
+ limit_chars=self._get_limit_chars(),
+ status=status,
+ )
+ except Exception as e:
+ logger.warning(f"Transcript render failed for node {node_id}: {e}")
+ return
+ if display and display != last_displayed_text:
+ logger.debug(
+ "PLATFORM_EDIT: node_id={} chat_id={} msg_id={} force={} status={!r} chars={}",
+ node_id,
+ chat_id,
+ status_msg_id,
+ bool(force),
+ status,
+ len(display),
+ )
+ if os.getenv("DEBUG_TELEGRAM_EDITS") == "1":
+ logger.debug("PLATFORM_EDIT_TEXT:\n{}", display)
+ else:
+ head = display[:500]
+ tail = display[-500:] if len(display) > 500 else ""
+ logger.debug("PLATFORM_EDIT_PREVIEW_HEAD:\n{}", head)
+ if tail:
+ logger.debug("PLATFORM_EDIT_PREVIEW_TAIL:\n{}", tail)
+ last_displayed_text = display
+ try:
+ await self.platform.queue_edit_message(
+ chat_id,
+ status_msg_id,
+ display,
+ parse_mode=self._parse_mode(),
+ )
+ except Exception as e:
+ logger.warning(f"Failed to update platform for node {node_id}: {e}")
+
+ try:
+ try:
+ (
+ cli_session,
+ session_or_temp_id,
+ is_new,
+ ) = await self.cli_manager.get_or_create_session(session_id=None)
+ if is_new:
+ temp_session_id = session_or_temp_id
+ else:
+ captured_session_id = session_or_temp_id
+ except RuntimeError as e:
+ error_message = get_user_facing_error_message(e)
+ transcript.apply({"type": "error", "message": error_message})
+ await update_ui(
+ self.format_status("⏳", "Session limit reached"),
+ force=True,
+ )
+ if tree:
+ await tree.update_state(
+ node_id,
+ MessageState.ERROR,
+ error_message=error_message,
+ )
+ return
+
+ logger.info(f"HANDLER: Starting CLI task processing for node {node_id}")
+ event_count = 0
+ async for event_data in cli_session.start_task(
+ incoming.text,
+ session_id=parent_session_id,
+ fork_session=bool(parent_session_id),
+ ):
+ if not isinstance(event_data, dict):
+ logger.warning(
+ f"HANDLER: Non-dict event received: {type(event_data)}"
+ )
+ continue
+ event_count += 1
+ if event_count % 10 == 0:
+ logger.debug(f"HANDLER: Processed {event_count} events so far")
+
+ (
+ captured_session_id,
+ temp_session_id,
+ ) = await self._handle_session_info_event(
+ event_data, tree, node_id, captured_session_id, temp_session_id
+ )
+ if event_data.get("type") == "session_info":
+ continue
+
+ parsed_list = parse_cli_event(event_data)
+ logger.debug(f"HANDLER: Parsed {len(parsed_list)} events from CLI")
+
+ for parsed in parsed_list:
+ (
+ last_status,
+ had_transcript_events,
+ ) = await self._process_parsed_event(
+ parsed,
+ transcript,
+ update_ui,
+ last_status,
+ had_transcript_events,
+ tree,
+ node_id,
+ captured_session_id,
+ )
+
+ except asyncio.CancelledError:
+ logger.warning(f"HANDLER: Task cancelled for node {node_id}")
+ cancel_reason = None
+ if isinstance(node.context, dict):
+ cancel_reason = node.context.get("cancel_reason")
+
+ if cancel_reason == "stop":
+ await update_ui(self.format_status("⏹", "Stopped."), force=True)
+ else:
+ transcript.apply({"type": "error", "message": "Task was cancelled"})
+ await update_ui(self.format_status("❌", "Cancelled"), force=True)
+
+ # Do not propagate cancellation to children; a reply-scoped "/stop"
+ # should only stop the targeted task.
+ if tree:
+ await tree.update_state(
+ node_id, MessageState.ERROR, error_message="Cancelled by user"
+ )
+ except Exception as e:
+ logger.error(
+ f"HANDLER: Task failed with exception: {type(e).__name__}: {e}"
+ )
+ error_msg = get_user_facing_error_message(e)[:200]
+ transcript.apply({"type": "error", "message": error_msg})
+ await update_ui(self.format_status("💥", "Task Failed"), force=True)
+ if tree:
+ await self._propagate_error_to_children(
+ node_id, error_msg, "Parent task failed"
+ )
+ finally:
+ logger.info(f"HANDLER: _process_node completed for node {node_id}")
+ # Free the session-manager slot. Session IDs are persisted in the tree and
+ # can be resumed later by ID; we don't need to keep a CLISession instance
+ # around after this node completes.
+ try:
+ if captured_session_id:
+ await self.cli_manager.remove_session(captured_session_id)
+ elif temp_session_id:
+ await self.cli_manager.remove_session(temp_session_id)
+ except Exception as e:
+ logger.debug(f"Failed to remove session for node {node_id}: {e}")
+
+ async def _propagate_error_to_children(
+ self,
+ node_id: str,
+ error_msg: str,
+ child_status_text: str,
+ ) -> None:
+ """Mark node as error and propagate to pending children with UI updates."""
+ affected = await self.tree_queue.mark_node_error(
+ node_id, error_msg, propagate_to_children=True
+ )
+ # Update status messages for all affected children (skip first = current node)
+ for child in affected[1:]:
+ self.platform.fire_and_forget(
+ self.platform.queue_edit_message(
+ child.incoming.chat_id,
+ child.status_message_id,
+ self.format_status("❌", "Cancelled:", child_status_text),
+ parse_mode=self._parse_mode(),
+ )
+ )
+
+ def _get_initial_status(
+ self,
+ tree: object | None,
+ parent_node_id: str | None,
+ ) -> str:
+ """Get initial status message text."""
+ if tree and parent_node_id:
+ # Reply to existing tree
+ if self.tree_queue.is_node_tree_busy(parent_node_id):
+ queue_size = self.tree_queue.get_queue_size(parent_node_id) + 1
+ return self.format_status(
+ "📋", "Queued", f"(position {queue_size}) - waiting..."
+ )
+ return self.format_status("🔄", "Continuing conversation...")
+
+ # New conversation
+ return self.format_status("⏳", "Launching new Claude CLI instance...")
+
+ async def stop_all_tasks(self) -> int:
+ """
+ Stop all pending and in-progress tasks.
+
+ Order of operations:
+ 1. Cancel tree queue tasks (uses internal locking)
+ 2. Stop CLI sessions
+ 3. Update UI for all affected nodes
+ """
+ # 1. Cancel tree queue tasks using the public async method
+ logger.info("Cancelling tree queue tasks...")
+ cancelled_nodes = await self.tree_queue.cancel_all()
+ logger.info(f"Cancelled {len(cancelled_nodes)} nodes")
+
+ # 2. Stop CLI sessions - this kills subprocesses and ensures everything is dead
+ logger.info("Stopping all CLI sessions...")
+ await self.cli_manager.stop_all()
+
+ # 3. Update UI and persist state for all cancelled nodes
+ self.update_cancelled_nodes_ui(cancelled_nodes)
+
+ return len(cancelled_nodes)
+
+ async def stop_task(self, node_id: str) -> int:
+ """
+ Stop a single queued or in-progress task node.
+
+ Used when the user replies "/stop" to a specific status/user message.
+ """
+ tree = self.tree_queue.get_tree_for_node(node_id)
+ if tree:
+ node = tree.get_node(node_id)
+ if node and node.state not in (MessageState.COMPLETED, MessageState.ERROR):
+ # Used by _process_node cancellation path to render "Stopped."
+ node.set_context({"cancel_reason": "stop"})
+
+ cancelled_nodes = await self.tree_queue.cancel_node(node_id)
+ self.update_cancelled_nodes_ui(cancelled_nodes)
+ return len(cancelled_nodes)
+
+ def record_outgoing_message(
+ self,
+ platform: str,
+ chat_id: str,
+ msg_id: str | None,
+ kind: str,
+ ) -> None:
+ """Record outgoing message ID for /clear. Best-effort, never raises."""
+ if not msg_id:
+ return
+ try:
+ self.session_store.record_message_id(
+ platform, chat_id, str(msg_id), direction="out", kind=kind
+ )
+ except Exception as e:
+ logger.debug(f"Failed to record message_id: {e}")
+
+ def update_cancelled_nodes_ui(self, nodes: list[MessageNode]) -> None:
+ """Update status messages and persist tree state for cancelled nodes."""
+ trees_to_save: dict[str, MessageTree] = {}
+ for node in nodes:
+ self.platform.fire_and_forget(
+ self.platform.queue_edit_message(
+ node.incoming.chat_id,
+ node.status_message_id,
+ self.format_status("⏹", "Stopped."),
+ parse_mode=self._parse_mode(),
+ )
+ )
+ tree = self.tree_queue.get_tree_for_node(node.node_id)
+ if tree:
+ trees_to_save[tree.root_id] = tree
+ for root_id, tree in trees_to_save.items():
+ self.session_store.save_tree(root_id, tree.to_dict())
+
+ async def _handle_stop_command(self, incoming: IncomingMessage) -> None:
+ """Handle /stop command from messaging platform."""
+ await handle_stop_command(self, incoming)
+
+ async def _handle_stats_command(self, incoming: IncomingMessage) -> None:
+ """Handle /stats command."""
+ await handle_stats_command(self, incoming)
+
+ async def _handle_clear_command(self, incoming: IncomingMessage) -> None:
+ """Handle /clear command."""
+ await handle_clear_command(self, incoming)
diff --git a/Claude_Code/messaging/limiter.py b/Claude_Code/messaging/limiter.py
new file mode 100644
index 0000000000000000000000000000000000000000..6367e9e0cd897ee52ec81949777a82fd4173663c
--- /dev/null
+++ b/Claude_Code/messaging/limiter.py
@@ -0,0 +1,312 @@
+"""
+Global Rate Limiter for Messaging Platforms.
+
+Centralizes outgoing message requests and ensures compliance with rate limits
+using a strict sliding window algorithm and a task queue.
+"""
+
+import asyncio
+import os
+import time
+from collections import deque
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+from loguru import logger
+
+
+class SlidingWindowLimiter:
+ """Strict sliding window limiter.
+
+ Guarantees: at most `rate_limit` acquisitions in any interval of length
+ `rate_window` (seconds).
+
+ Implemented as an async context manager so call sites can do:
+ async with limiter:
+ ...
+ """
+
+ def __init__(self, rate_limit: int, rate_window: float) -> None:
+ if rate_limit <= 0:
+ raise ValueError("rate_limit must be > 0")
+ if rate_window <= 0:
+ raise ValueError("rate_window must be > 0")
+
+ self._rate_limit = int(rate_limit)
+ self._rate_window = float(rate_window)
+ self._times: deque[float] = deque()
+ self._lock = asyncio.Lock()
+
+ async def acquire(self) -> None:
+ while True:
+ wait_time = 0.0
+ async with self._lock:
+ now = time.monotonic()
+ cutoff = now - self._rate_window
+
+ while self._times and self._times[0] <= cutoff:
+ self._times.popleft()
+
+ if len(self._times) < self._rate_limit:
+ self._times.append(now)
+ return
+
+ oldest = self._times[0]
+ wait_time = max(0.0, (oldest + self._rate_window) - now)
+
+ if wait_time > 0:
+ await asyncio.sleep(wait_time)
+ else:
+ await asyncio.sleep(0)
+
+ async def __aenter__(self) -> SlidingWindowLimiter:
+ await self.acquire()
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb) -> bool:
+ return False
+
+
+class MessagingRateLimiter:
+ """
+ A thread-safe global rate limiter for messaging.
+
+ Uses a custom queue with task compaction (deduplication) to ensure
+ only the latest version of a message update is processed.
+ """
+
+ _instance: MessagingRateLimiter | None = None
+ _lock = asyncio.Lock()
+
+ def __new__(cls, *args, **kwargs):
+ return super().__new__(cls)
+
+ @classmethod
+ async def get_instance(cls) -> MessagingRateLimiter:
+ """Get the singleton instance of the limiter."""
+ async with cls._lock:
+ if cls._instance is None:
+ cls._instance = cls()
+ # Start the background worker (tracked for graceful shutdown).
+ cls._instance._start_worker()
+ return cls._instance
+
+ def __init__(self):
+ # Prevent double initialization in singleton
+ if hasattr(self, "_initialized"):
+ return
+
+ rate_limit = int(os.getenv("MESSAGING_RATE_LIMIT", "1"))
+ rate_window = float(os.getenv("MESSAGING_RATE_WINDOW", "2.0"))
+
+ self.limiter = SlidingWindowLimiter(rate_limit, rate_window)
+ # Custom queue state - using deque for O(1) popleft
+ self._queue_list: deque[str] = deque() # Deque of dedup_keys in order
+ self._queue_map: dict[
+ str, tuple[Callable[[], Awaitable[Any]], list[asyncio.Future]]
+ ] = {}
+ self._condition = asyncio.Condition()
+ self._shutdown = asyncio.Event()
+ self._worker_task: asyncio.Task | None = None
+
+ self._initialized = True
+ self._paused_until = 0
+
+ logger.info(
+ f"MessagingRateLimiter initialized ({rate_limit} req / {rate_window}s with Task Compaction)"
+ )
+
+ def _start_worker(self) -> None:
+ """Ensure the worker task exists."""
+ if self._worker_task and not self._worker_task.done():
+ return
+ # Named task helps debugging shutdown hangs.
+ self._worker_task = asyncio.create_task(
+ self._worker(), name="msg-limiter-worker"
+ )
+
+ async def _worker(self):
+ """Background worker that processes queued messaging tasks."""
+ logger.info("MessagingRateLimiter worker started")
+ while not self._shutdown.is_set():
+ try:
+ # Get a task from the queue
+ async with self._condition:
+ while not self._queue_list and not self._shutdown.is_set():
+ await self._condition.wait()
+
+ if self._shutdown.is_set():
+ break
+
+ dedup_key = self._queue_list.popleft()
+ func, futures = self._queue_map.pop(dedup_key)
+
+ # Check for manual pause (FloodWait)
+ now = asyncio.get_event_loop().time()
+ if self._paused_until > now:
+ wait_time = self._paused_until - now
+ logger.warning(
+ f"Limiter worker paused, waiting {wait_time:.1f}s more..."
+ )
+ await asyncio.sleep(wait_time)
+
+ # Wait for rate limit capacity
+ async with self.limiter:
+ try:
+ result = await func()
+ for f in futures:
+ if not f.done():
+ f.set_result(result)
+ except Exception as e:
+ # Report error to all futures and log it
+ for f in futures:
+ if not f.done():
+ f.set_exception(e)
+
+ error_msg = str(e).lower()
+ if "flood" in error_msg or "wait" in error_msg:
+ seconds = 30
+ try:
+ if hasattr(e, "seconds"):
+ seconds = e.seconds
+ elif "after " in error_msg:
+ # Try to parse "retry after X"
+ parts = error_msg.split("after ")
+ if len(parts) > 1:
+ seconds = int(parts[1].split()[0])
+ except Exception:
+ pass
+
+ logger.error(
+ f"FloodWait detected! Pausing worker for {seconds}s"
+ )
+ wait_secs = (
+ float(seconds)
+ if isinstance(seconds, (int, float, str))
+ else 30.0
+ )
+ self._paused_until = (
+ asyncio.get_event_loop().time() + wait_secs
+ )
+ else:
+ logger.error(
+ f"Error in limiter worker for key {dedup_key}: {type(e).__name__}: {e}"
+ )
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(
+ f"MessagingRateLimiter worker critical error: {e}", exc_info=True
+ )
+ await asyncio.sleep(1)
+
+ async def shutdown(self, timeout: float = 2.0) -> None:
+ """Stop the background worker so process shutdown doesn't hang."""
+ self._shutdown.set()
+ try:
+ async with self._condition:
+ self._condition.notify_all()
+ except Exception:
+ # Best-effort: condition may be bound to a closing loop.
+ pass
+
+ task = self._worker_task
+ if not task or task.done():
+ self._worker_task = None
+ return
+
+ task.cancel()
+ try:
+ await asyncio.wait_for(task, timeout=timeout)
+ except TimeoutError:
+ logger.warning("MessagingRateLimiter worker did not stop before timeout")
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ logger.debug(f"MessagingRateLimiter worker shutdown error: {e}")
+ finally:
+ self._worker_task = None
+
+ @classmethod
+ async def shutdown_instance(cls, timeout: float = 2.0) -> None:
+ """Shutdown and clear the singleton instance (safe to call multiple times)."""
+ inst = cls._instance
+ if not inst:
+ return
+ try:
+ await inst.shutdown(timeout=timeout)
+ finally:
+ cls._instance = None
+
+ async def _enqueue_internal(self, func, future, dedup_key, front=False):
+ await self._enqueue_internal_multi(func, [future], dedup_key, front)
+
+ async def _enqueue_internal_multi(self, func, futures, dedup_key, front=False):
+ async with self._condition:
+ if dedup_key in self._queue_map:
+ # Compaction: Update existing task with new func, append new futures
+ _old_func, old_futures = self._queue_map[dedup_key]
+ old_futures.extend(futures)
+ self._queue_map[dedup_key] = (func, old_futures)
+ logger.debug(
+ f"Compacted task for key: {dedup_key} (now {len(old_futures)} futures)"
+ )
+ else:
+ self._queue_map[dedup_key] = (func, futures)
+ if front:
+ self._queue_list.appendleft(dedup_key)
+ else:
+ self._queue_list.append(dedup_key)
+ self._condition.notify_all()
+
+ async def enqueue(
+ self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None
+ ) -> Any:
+ """
+ Enqueue a messaging task and return its future result.
+ If dedup_key is provided, subsequent tasks with the same key will replace this one.
+ """
+ if dedup_key is None:
+ # Unique key to avoid deduplication
+ dedup_key = f"task_{id(func)}_{asyncio.get_event_loop().time()}"
+
+ future = asyncio.get_event_loop().create_future()
+ await self._enqueue_internal(func, future, dedup_key)
+ return await future
+
+ def fire_and_forget(
+ self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None
+ ):
+ """Enqueue a task without waiting for the result."""
+ if dedup_key is None:
+ dedup_key = f"task_{id(func)}_{asyncio.get_event_loop().time()}"
+
+ future = asyncio.get_event_loop().create_future()
+
+ async def _wrapped():
+ max_retries = 2
+ for attempt in range(max_retries + 1):
+ try:
+ return await self.enqueue(func, dedup_key)
+ except Exception as e:
+ error_msg = str(e).lower()
+ # Only retry transient connectivity issues that might have slipped through
+ # or occurred between platform checks.
+ if attempt < max_retries and any(
+ x in error_msg for x in ["connect", "timeout", "broken"]
+ ):
+ wait = 2**attempt
+ logger.warning(
+ f"Limiter fire_and_forget transient error (attempt {attempt + 1}): {e}. Retrying in {wait}s..."
+ )
+ await asyncio.sleep(wait)
+ continue
+
+ logger.error(
+ f"Final error in fire_and_forget for key {dedup_key}: {type(e).__name__}: {e}"
+ )
+ if not future.done():
+ future.set_exception(e)
+ break
+
+ _ = asyncio.create_task(_wrapped())
diff --git a/Claude_Code/messaging/models.py b/Claude_Code/messaging/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..b63f4f7def24c116b87c7709c8ca043ccb5c4227
--- /dev/null
+++ b/Claude_Code/messaging/models.py
@@ -0,0 +1,36 @@
+"""Platform-agnostic message models."""
+
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from typing import Any
+
+
+@dataclass
+class IncomingMessage:
+ """
+ Platform-agnostic incoming message.
+
+ Adapters convert platform-specific events to this format.
+ """
+
+ text: str
+ chat_id: str
+ user_id: str
+ message_id: str
+ platform: str # "telegram", "discord", "slack", etc.
+
+ # Optional fields
+ reply_to_message_id: str | None = None
+ # Forum topic ID (Telegram); required when replying in forum supergroups
+ message_thread_id: str | None = None
+ username: str | None = None
+ # Pre-sent status message ID (e.g. "Transcribing voice note..."); handler edits in place
+ status_message_id: str | None = None
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
+
+ # Platform-specific raw event for edge cases
+ raw_event: Any = None
+
+ def is_reply(self) -> bool:
+ """Check if this message is a reply to another message."""
+ return self.reply_to_message_id is not None
diff --git a/Claude_Code/messaging/platforms/__init__.py b/Claude_Code/messaging/platforms/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..88261a7a38273485f3d20ea0da66053cf6d7e9bf
--- /dev/null
+++ b/Claude_Code/messaging/platforms/__init__.py
@@ -0,0 +1,11 @@
+"""Messaging platform adapters (Telegram, Discord, etc.)."""
+
+from .base import CLISession, MessagingPlatform, SessionManagerInterface
+from .factory import create_messaging_platform
+
+__all__ = [
+ "CLISession",
+ "MessagingPlatform",
+ "SessionManagerInterface",
+ "create_messaging_platform",
+]
diff --git a/Claude_Code/messaging/platforms/base.py b/Claude_Code/messaging/platforms/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..b45c7f09bcff7ae78f07cc719eac302810835d80
--- /dev/null
+++ b/Claude_Code/messaging/platforms/base.py
@@ -0,0 +1,218 @@
+"""Abstract base class for messaging platforms."""
+
+from abc import ABC, abstractmethod
+from collections.abc import AsyncGenerator, Awaitable, Callable
+from typing import (
+ Any,
+ Protocol,
+ runtime_checkable,
+)
+
+from ..models import IncomingMessage
+
+
+@runtime_checkable
+class CLISession(Protocol):
+ """Protocol for CLI session - avoid circular import from cli package."""
+
+ def start_task(
+ self, prompt: str, session_id: str | None = None, fork_session: bool = False
+ ) -> AsyncGenerator[dict, Any]:
+ """Start a task in the CLI session."""
+ ...
+
+ @property
+ @abstractmethod
+ def is_busy(self) -> bool:
+ """Check if session is busy."""
+ pass
+
+
+@runtime_checkable
+class SessionManagerInterface(Protocol):
+ """
+ Protocol for session managers to avoid tight coupling with cli package.
+
+ Implementations: CLISessionManager
+ """
+
+ async def get_or_create_session(
+ self, session_id: str | None = None
+ ) -> tuple[CLISession, str, bool]:
+ """
+ Get an existing session or create a new one.
+
+ Returns: Tuple of (session, session_id, is_new_session)
+ """
+ ...
+
+ async def register_real_session_id(
+ self, temp_id: str, real_session_id: str
+ ) -> bool:
+ """Register the real session ID from CLI output."""
+ ...
+
+ async def stop_all(self) -> None:
+ """Stop all sessions."""
+ ...
+
+ async def remove_session(self, session_id: str) -> bool:
+ """Remove a session from the manager."""
+ ...
+
+ def get_stats(self) -> dict:
+ """Get session statistics."""
+ ...
+
+
+class MessagingPlatform(ABC):
+ """
+ Base class for all messaging platform adapters.
+
+ Implement this to add support for Telegram, Discord, Slack, etc.
+ """
+
+ name: str = "base"
+
+ @abstractmethod
+ async def start(self) -> None:
+ """Initialize and connect to the messaging platform."""
+ pass
+
+ @abstractmethod
+ async def stop(self) -> None:
+ """Disconnect and cleanup resources."""
+ pass
+
+ @abstractmethod
+ async def send_message(
+ self,
+ chat_id: str,
+ text: str,
+ reply_to: str | None = None,
+ parse_mode: str | None = None,
+ message_thread_id: str | None = None,
+ ) -> str:
+ """
+ Send a message to a chat.
+
+ Args:
+ chat_id: The chat/channel ID to send to
+ text: Message content
+ reply_to: Optional message ID to reply to
+ parse_mode: Optional formatting mode ("markdown", "html")
+ message_thread_id: Optional forum topic ID (Telegram)
+
+ Returns:
+ The message ID of the sent message
+ """
+ pass
+
+ @abstractmethod
+ async def edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ text: str,
+ parse_mode: str | None = None,
+ ) -> None:
+ """
+ Edit an existing message.
+
+ Args:
+ chat_id: The chat/channel ID
+ message_id: The message ID to edit
+ text: New message content
+ parse_mode: Optional formatting mode
+ """
+ pass
+
+ @abstractmethod
+ async def delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ ) -> None:
+ """
+ Delete a message from a chat.
+
+ Args:
+ chat_id: The chat/channel ID
+ message_id: The message ID to delete
+ """
+ pass
+
+ @abstractmethod
+ async def queue_send_message(
+ self,
+ chat_id: str,
+ text: str,
+ reply_to: str | None = None,
+ parse_mode: str | None = None,
+ fire_and_forget: bool = True,
+ message_thread_id: str | None = None,
+ ) -> str | None:
+ """
+ Enqueue a message to be sent.
+
+ If fire_and_forget is True, returns None immediately.
+ Otherwise, waits for the rate limiter and returns message ID.
+ """
+ pass
+
+ @abstractmethod
+ async def queue_edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ text: str,
+ parse_mode: str | None = None,
+ fire_and_forget: bool = True,
+ ) -> None:
+ """
+ Enqueue a message edit.
+
+ If fire_and_forget is True, returns immediately.
+ Otherwise, waits for the rate limiter.
+ """
+ pass
+
+ @abstractmethod
+ async def queue_delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ fire_and_forget: bool = True,
+ ) -> None:
+ """
+ Enqueue a message deletion.
+
+ If fire_and_forget is True, returns immediately.
+ Otherwise, waits for the rate limiter.
+ """
+ pass
+
+ @abstractmethod
+ def on_message(
+ self,
+ handler: Callable[[IncomingMessage], Awaitable[None]],
+ ) -> None:
+ """
+ Register a message handler callback.
+
+ The handler will be called for each incoming message.
+
+ Args:
+ handler: Async function that processes incoming messages
+ """
+ pass
+
+ @abstractmethod
+ def fire_and_forget(self, task: Awaitable[Any]) -> None:
+ """Execute a coroutine without awaiting it."""
+ pass
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if the platform is connected."""
+ return False
diff --git a/Claude_Code/messaging/platforms/discord.py b/Claude_Code/messaging/platforms/discord.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0bbd1da83e75bab738ba062dae40c5c3133312f
--- /dev/null
+++ b/Claude_Code/messaging/platforms/discord.py
@@ -0,0 +1,561 @@
+"""
+Discord Platform Adapter
+
+Implements MessagingPlatform for Discord using discord.py.
+"""
+
+import asyncio
+import contextlib
+import os
+import tempfile
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+from typing import Any, cast
+
+from loguru import logger
+
+from providers.common import get_user_facing_error_message
+
+from ..models import IncomingMessage
+from ..rendering.discord_markdown import format_status_discord
+from .base import MessagingPlatform
+
+AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
+
+_discord_module: Any = None
+try:
+ import discord as _discord_import
+
+ _discord_module = _discord_import
+ DISCORD_AVAILABLE = True
+except ImportError:
+ DISCORD_AVAILABLE = False
+
+DISCORD_MESSAGE_LIMIT = 2000
+
+
+def _get_discord() -> Any:
+ """Return the discord module. Raises if not available."""
+ if not DISCORD_AVAILABLE or _discord_module is None:
+ raise ImportError(
+ "discord.py is required. Install with: pip install discord.py"
+ )
+ return _discord_module
+
+
+def _parse_allowed_channels(raw: str | None) -> set[str]:
+ """Parse comma-separated channel IDs into a set of strings."""
+ if not raw or not raw.strip():
+ return set()
+ return {s.strip() for s in raw.split(",") if s.strip()}
+
+
+if DISCORD_AVAILABLE and _discord_module is not None:
+ _discord = _discord_module
+
+ class _DiscordClient(_discord.Client):
+ """Internal Discord client that forwards events to DiscordPlatform."""
+
+ def __init__(
+ self,
+ platform: DiscordPlatform,
+ intents: _discord.Intents,
+ ) -> None:
+ super().__init__(intents=intents)
+ self._platform = platform
+
+ async def on_ready(self) -> None:
+ """Called when the bot is ready."""
+ self._platform._connected = True
+ logger.info("Discord platform connected")
+
+ async def on_message(self, message: Any) -> None:
+ """Handle incoming Discord messages."""
+ await self._platform._handle_client_message(message)
+else:
+ _DiscordClient = None
+
+
+class DiscordPlatform(MessagingPlatform):
+ """
+ Discord messaging platform adapter.
+
+ Uses discord.py for Discord access.
+ Requires a Bot Token from Discord Developer Portal and message_content intent.
+ """
+
+ name = "discord"
+
+ def __init__(
+ self,
+ bot_token: str | None = None,
+ allowed_channel_ids: str | None = None,
+ ):
+ if not DISCORD_AVAILABLE:
+ raise ImportError(
+ "discord.py is required. Install with: pip install discord.py"
+ )
+
+ self.bot_token = bot_token or os.getenv("DISCORD_BOT_TOKEN")
+ raw_channels = allowed_channel_ids or os.getenv("ALLOWED_DISCORD_CHANNELS")
+ self.allowed_channel_ids = _parse_allowed_channels(raw_channels)
+
+ if not self.bot_token:
+ logger.warning("DISCORD_BOT_TOKEN not set")
+
+ discord = _get_discord()
+ intents = discord.Intents.default()
+ intents.message_content = True
+
+ assert _DiscordClient is not None
+ self._client = _DiscordClient(self, intents)
+ self._message_handler: Callable[[IncomingMessage], Awaitable[None]] | None = (
+ None
+ )
+ self._connected = False
+ self._limiter: Any | None = None
+ self._start_task: asyncio.Task | None = None
+ self._pending_voice: dict[tuple[str, str], tuple[str, str]] = {}
+ self._pending_voice_lock = asyncio.Lock()
+
+ async def _handle_client_message(self, message: Any) -> None:
+ """Adapter entry point used by the internal discord client."""
+ await self._on_discord_message(message)
+
+ async def _register_pending_voice(
+ self, chat_id: str, voice_msg_id: str, status_msg_id: str
+ ) -> None:
+ """Register a voice note as pending transcription."""
+ async with self._pending_voice_lock:
+ self._pending_voice[(chat_id, voice_msg_id)] = (voice_msg_id, status_msg_id)
+ self._pending_voice[(chat_id, status_msg_id)] = (
+ voice_msg_id,
+ status_msg_id,
+ )
+
+ async def cancel_pending_voice(
+ self, chat_id: str, reply_id: str
+ ) -> tuple[str, str] | None:
+ """Cancel a pending voice transcription. Returns (voice_msg_id, status_msg_id) if found."""
+ async with self._pending_voice_lock:
+ entry = self._pending_voice.pop((chat_id, reply_id), None)
+ if entry is None:
+ return None
+ voice_msg_id, status_msg_id = entry
+ self._pending_voice.pop((chat_id, voice_msg_id), None)
+ self._pending_voice.pop((chat_id, status_msg_id), None)
+ return (voice_msg_id, status_msg_id)
+
+ async def _is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
+ """Check if a voice note is still pending (not cancelled)."""
+ async with self._pending_voice_lock:
+ return (chat_id, voice_msg_id) in self._pending_voice
+
+ def _get_audio_attachment(self, message: Any) -> Any | None:
+ """Return first audio attachment, or None."""
+ for att in message.attachments:
+ ct = (att.content_type or "").lower()
+ fn = (att.filename or "").lower()
+ if ct.startswith("audio/") or any(
+ fn.endswith(ext) for ext in AUDIO_EXTENSIONS
+ ):
+ return att
+ return None
+
+ async def _handle_voice_note(
+ self, message: Any, attachment: Any, channel_id: str
+ ) -> bool:
+ """Handle voice/audio attachment. Returns True if handled."""
+ from config.settings import get_settings
+
+ settings = get_settings()
+ if not settings.voice_note_enabled:
+ await message.reply("Voice notes are disabled.")
+ return True
+
+ if not self._message_handler:
+ return False
+
+ status_msg_id = await self.queue_send_message(
+ channel_id,
+ format_status_discord("Transcribing voice note..."),
+ reply_to=str(message.id),
+ fire_and_forget=False,
+ )
+
+ user_id = str(message.author.id)
+ message_id = str(message.id)
+ await self._register_pending_voice(channel_id, message_id, str(status_msg_id))
+ reply_to = (
+ str(message.reference.message_id)
+ if message.reference and message.reference.message_id
+ else None
+ )
+
+ ext = ".ogg"
+ fn = (attachment.filename or "").lower()
+ for e in AUDIO_EXTENSIONS:
+ if fn.endswith(e):
+ ext = e
+ break
+ ct = attachment.content_type or "audio/ogg"
+ if "mp4" in ct or "m4a" in fn:
+ ext = ".m4a" if "m4a" in fn else ".mp4"
+ elif "mp3" in ct or fn.endswith(".mp3"):
+ ext = ".mp3"
+
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
+ tmp_path = Path(tmp.name)
+
+ try:
+ await attachment.save(str(tmp_path))
+
+ from ..transcription import transcribe_audio
+
+ transcribed = await asyncio.to_thread(
+ transcribe_audio,
+ tmp_path,
+ ct,
+ whisper_model=settings.whisper_model,
+ whisper_device=settings.whisper_device,
+ )
+
+ if not await self._is_voice_still_pending(channel_id, message_id):
+ await self.queue_delete_message(channel_id, str(status_msg_id))
+ return True
+
+ async with self._pending_voice_lock:
+ self._pending_voice.pop((channel_id, message_id), None)
+ self._pending_voice.pop((channel_id, str(status_msg_id)), None)
+
+ incoming = IncomingMessage(
+ text=transcribed,
+ chat_id=channel_id,
+ user_id=user_id,
+ message_id=message_id,
+ platform="discord",
+ reply_to_message_id=reply_to,
+ username=message.author.display_name,
+ raw_event=message,
+ status_message_id=status_msg_id,
+ )
+
+ logger.info(
+ "DISCORD_VOICE: chat_id={} message_id={} transcribed={!r}",
+ channel_id,
+ message_id,
+ (transcribed[:80] + "..." if len(transcribed) > 80 else transcribed),
+ )
+
+ await self._message_handler(incoming)
+ return True
+ except ValueError as e:
+ await message.reply(get_user_facing_error_message(e)[:200])
+ return True
+ except ImportError as e:
+ await message.reply(get_user_facing_error_message(e)[:200])
+ return True
+ except Exception as e:
+ logger.error(f"Voice transcription failed: {e}")
+ await message.reply(
+ "Could not transcribe voice note. Please try again or send text."
+ )
+ return True
+ finally:
+ with contextlib.suppress(OSError):
+ tmp_path.unlink(missing_ok=True)
+
+ async def _on_discord_message(self, message: Any) -> None:
+ """Handle incoming Discord messages."""
+ if message.author.bot:
+ return
+
+ channel_id = str(message.channel.id)
+
+ if not self.allowed_channel_ids or channel_id not in self.allowed_channel_ids:
+ return
+
+ # Handle voice/audio attachments when message has no text content
+ if not message.content:
+ audio_att = self._get_audio_attachment(message)
+ if audio_att:
+ await self._handle_voice_note(message, audio_att, channel_id)
+ return
+ return
+
+ user_id = str(message.author.id)
+ message_id = str(message.id)
+ reply_to = (
+ str(message.reference.message_id)
+ if message.reference and message.reference.message_id
+ else None
+ )
+
+ text_preview = (message.content or "")[:80]
+ if len(message.content or "") > 80:
+ text_preview += "..."
+ logger.info(
+ "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}",
+ channel_id,
+ message_id,
+ reply_to,
+ text_preview,
+ )
+
+ if not self._message_handler:
+ return
+
+ incoming = IncomingMessage(
+ text=message.content,
+ chat_id=channel_id,
+ user_id=user_id,
+ message_id=message_id,
+ platform="discord",
+ reply_to_message_id=reply_to,
+ username=message.author.display_name,
+ raw_event=message,
+ )
+
+ try:
+ await self._message_handler(incoming)
+ except Exception as e:
+ logger.error(f"Error handling message: {e}")
+ with contextlib.suppress(Exception):
+ await self.send_message(
+ channel_id,
+ format_status_discord(
+ "Error:", get_user_facing_error_message(e)[:200]
+ ),
+ reply_to=message_id,
+ )
+
+ def _truncate(self, text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str:
+ """Truncate text to Discord's message limit."""
+ if len(text) <= limit:
+ return text
+ return text[: limit - 3] + "..."
+
+ async def start(self) -> None:
+ """Initialize and connect to Discord."""
+ if not self.bot_token:
+ raise ValueError("DISCORD_BOT_TOKEN is required")
+
+ from ..limiter import MessagingRateLimiter
+
+ self._limiter = await MessagingRateLimiter.get_instance()
+
+ self._start_task = asyncio.create_task(
+ self._client.start(self.bot_token),
+ name="discord-client-start",
+ )
+
+ max_wait = 30
+ waited = 0
+ while not self._connected and waited < max_wait:
+ await asyncio.sleep(0.5)
+ waited += 0.5
+
+ if not self._connected:
+ raise RuntimeError("Discord client failed to connect within timeout")
+
+ logger.info("Discord platform started")
+
+ async def stop(self) -> None:
+ """Stop the bot."""
+ if self._client.is_closed():
+ self._connected = False
+ return
+
+ await self._client.close()
+ if self._start_task and not self._start_task.done():
+ try:
+ await asyncio.wait_for(self._start_task, timeout=5.0)
+ except TimeoutError, asyncio.CancelledError:
+ self._start_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._start_task
+
+ self._connected = False
+ logger.info("Discord platform stopped")
+
+ async def send_message(
+ self,
+ chat_id: str,
+ text: str,
+ reply_to: str | None = None,
+ parse_mode: str | None = None,
+ message_thread_id: str | None = None,
+ ) -> str:
+ """Send a message to a channel."""
+ channel = self._client.get_channel(int(chat_id))
+ if not channel or not hasattr(channel, "send"):
+ raise RuntimeError(f"Channel {chat_id} not found")
+
+ text = self._truncate(text)
+ channel = cast(Any, channel)
+
+ discord = _get_discord()
+ if reply_to:
+ ref = discord.MessageReference(
+ message_id=int(reply_to),
+ channel_id=int(chat_id),
+ )
+ msg = await channel.send(content=text, reference=ref)
+ else:
+ msg = await channel.send(content=text)
+
+ return str(msg.id)
+
+ async def edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ text: str,
+ parse_mode: str | None = None,
+ ) -> None:
+ """Edit an existing message."""
+ channel = self._client.get_channel(int(chat_id))
+ if not channel or not hasattr(channel, "fetch_message"):
+ raise RuntimeError(f"Channel {chat_id} not found")
+
+ discord = _get_discord()
+ channel = cast(Any, channel)
+ try:
+ msg = await channel.fetch_message(int(message_id))
+ except discord.NotFound:
+ return
+
+ text = self._truncate(text)
+ await msg.edit(content=text)
+
+ async def delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ ) -> None:
+ """Delete a message from a channel."""
+ channel = self._client.get_channel(int(chat_id))
+ if not channel or not hasattr(channel, "fetch_message"):
+ return
+
+ discord = _get_discord()
+ channel = cast(Any, channel)
+ try:
+ msg = await channel.fetch_message(int(message_id))
+ await msg.delete()
+ except discord.NotFound, discord.Forbidden:
+ pass
+
+ async def delete_messages(self, chat_id: str, message_ids: list[str]) -> None:
+ """Delete multiple messages (best-effort)."""
+ for mid in message_ids:
+ await self.delete_message(chat_id, mid)
+
+ async def queue_send_message(
+ self,
+ chat_id: str,
+ text: str,
+ reply_to: str | None = None,
+ parse_mode: str | None = None,
+ fire_and_forget: bool = True,
+ message_thread_id: str | None = None,
+ ) -> str | None:
+ """Enqueue a message to be sent."""
+ if not self._limiter:
+ return await self.send_message(
+ chat_id, text, reply_to, parse_mode, message_thread_id
+ )
+
+ async def _send():
+ return await self.send_message(
+ chat_id, text, reply_to, parse_mode, message_thread_id
+ )
+
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_send)
+ return None
+ return await self._limiter.enqueue(_send)
+
+ async def queue_edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ text: str,
+ parse_mode: str | None = None,
+ fire_and_forget: bool = True,
+ ) -> None:
+ """Enqueue a message edit."""
+ if not self._limiter:
+ await self.edit_message(chat_id, message_id, text, parse_mode)
+ return
+
+ async def _edit():
+ await self.edit_message(chat_id, message_id, text, parse_mode)
+
+ dedup_key = f"edit:{chat_id}:{message_id}"
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_edit, dedup_key=dedup_key)
+ else:
+ await self._limiter.enqueue(_edit, dedup_key=dedup_key)
+
+ async def queue_delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ fire_and_forget: bool = True,
+ ) -> None:
+ """Enqueue a message delete."""
+ if not self._limiter:
+ await self.delete_message(chat_id, message_id)
+ return
+
+ async def _delete():
+ await self.delete_message(chat_id, message_id)
+
+ dedup_key = f"del:{chat_id}:{message_id}"
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_delete, dedup_key=dedup_key)
+ else:
+ await self._limiter.enqueue(_delete, dedup_key=dedup_key)
+
+ async def queue_delete_messages(
+ self,
+ chat_id: str,
+ message_ids: list[str],
+ fire_and_forget: bool = True,
+ ) -> None:
+ """Enqueue a bulk delete."""
+ if not message_ids:
+ return
+
+ if not self._limiter:
+ await self.delete_messages(chat_id, message_ids)
+ return
+
+ async def _bulk():
+ await self.delete_messages(chat_id, message_ids)
+
+ dedup_key = f"del_bulk:{chat_id}:{hash(tuple(message_ids))}"
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_bulk, dedup_key=dedup_key)
+ else:
+ await self._limiter.enqueue(_bulk, dedup_key=dedup_key)
+
+ def fire_and_forget(self, task: Awaitable[Any]) -> None:
+ """Execute a coroutine without awaiting it."""
+ if asyncio.iscoroutine(task):
+ _ = asyncio.create_task(task)
+ else:
+ _ = asyncio.ensure_future(task)
+
+ def on_message(
+ self,
+ handler: Callable[[IncomingMessage], Awaitable[None]],
+ ) -> None:
+ """Register a message handler callback."""
+ self._message_handler = handler
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if connected."""
+ return self._connected
diff --git a/Claude_Code/messaging/platforms/factory.py b/Claude_Code/messaging/platforms/factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..f260c3b3fac0311cc90e70900cb58937ac3f4f7f
--- /dev/null
+++ b/Claude_Code/messaging/platforms/factory.py
@@ -0,0 +1,56 @@
+"""Messaging platform factory.
+
+Creates the appropriate messaging platform adapter based on configuration.
+To add a new platform (e.g. Discord, Slack):
+1. Create a new class implementing MessagingPlatform in messaging/platforms/
+2. Add a case to create_messaging_platform() below
+"""
+
+from loguru import logger
+
+from .base import MessagingPlatform
+
+
+def create_messaging_platform(
+ platform_type: str,
+ **kwargs,
+) -> MessagingPlatform | None:
+ """Create a messaging platform instance based on type.
+
+ Args:
+ platform_type: Platform identifier ("telegram", "discord", etc.)
+ **kwargs: Platform-specific configuration passed to the constructor.
+
+ Returns:
+ Configured MessagingPlatform instance, or None if not configured.
+ """
+ if platform_type == "telegram":
+ bot_token = kwargs.get("bot_token")
+ if not bot_token:
+ logger.info("No Telegram bot token configured, skipping platform setup")
+ return None
+
+ from .telegram import TelegramPlatform
+
+ return TelegramPlatform(
+ bot_token=bot_token,
+ allowed_user_id=kwargs.get("allowed_user_id"),
+ )
+
+ if platform_type == "discord":
+ bot_token = kwargs.get("discord_bot_token")
+ if not bot_token:
+ logger.info("No Discord bot token configured, skipping platform setup")
+ return None
+
+ from .discord import DiscordPlatform
+
+ return DiscordPlatform(
+ bot_token=bot_token,
+ allowed_channel_ids=kwargs.get("allowed_discord_channels"),
+ )
+
+ logger.warning(
+ f"Unknown messaging platform: '{platform_type}'. Supported: 'telegram', 'discord'"
+ )
+ return None
diff --git a/Claude_Code/messaging/platforms/telegram.py b/Claude_Code/messaging/platforms/telegram.py
new file mode 100644
index 0000000000000000000000000000000000000000..e38ab1e5138b3005d42b9c49a36e6b71566d868c
--- /dev/null
+++ b/Claude_Code/messaging/platforms/telegram.py
@@ -0,0 +1,661 @@
+"""
+Telegram Platform Adapter
+
+Implements MessagingPlatform for Telegram using python-telegram-bot.
+"""
+
+import asyncio
+import contextlib
+import os
+import tempfile
+from pathlib import Path
+
+# Opt-in to future behavior for python-telegram-bot (retry_after as timedelta)
+# This must be set BEFORE importing telegram.error
+os.environ["PTB_TIMEDELTA"] = "1"
+
+from collections.abc import Awaitable, Callable
+from typing import TYPE_CHECKING, Any
+
+from loguru import logger
+
+from providers.common import get_user_facing_error_message
+
+if TYPE_CHECKING:
+ from telegram import Update
+ from telegram.ext import ContextTypes
+
+from ..models import IncomingMessage
+from ..rendering.telegram_markdown import escape_md_v2, format_status
+from .base import MessagingPlatform
+
+# Optional import - python-telegram-bot may not be installed
+try:
+ from telegram import Update
+ from telegram.error import NetworkError, RetryAfter, TelegramError
+ from telegram.ext import (
+ Application,
+ CommandHandler,
+ ContextTypes,
+ MessageHandler,
+ filters,
+ )
+ from telegram.request import HTTPXRequest
+
+ TELEGRAM_AVAILABLE = True
+except ImportError:
+ TELEGRAM_AVAILABLE = False
+
+
+class TelegramPlatform(MessagingPlatform):
+ """
+ Telegram messaging platform adapter.
+
+ Uses python-telegram-bot (BoT API) for Telegram access.
+ Requires a Bot Token from @BotFather.
+ """
+
+ name = "telegram"
+
+ def __init__(
+ self,
+ bot_token: str | None = None,
+ allowed_user_id: str | None = None,
+ ):
+ if not TELEGRAM_AVAILABLE:
+ raise ImportError(
+ "python-telegram-bot is required. Install with: pip install python-telegram-bot"
+ )
+
+ self.bot_token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
+ self.allowed_user_id = allowed_user_id or os.getenv("ALLOWED_TELEGRAM_USER_ID")
+
+ if not self.bot_token:
+ # We don't raise here to allow instantiation for testing/conditional logic,
+ # but start() will fail.
+ logger.warning("TELEGRAM_BOT_TOKEN not set")
+
+ self._application: Application | None = None
+ self._message_handler: Callable[[IncomingMessage], Awaitable[None]] | None = (
+ None
+ )
+ self._connected = False
+ self._limiter: Any | None = None # Will be MessagingRateLimiter
+ # Pending voice transcriptions: (chat_id, msg_id) -> (voice_msg_id, status_msg_id)
+ self._pending_voice: dict[tuple[str, str], tuple[str, str]] = {}
+ self._pending_voice_lock = asyncio.Lock()
+
+ async def _register_pending_voice(
+ self, chat_id: str, voice_msg_id: str, status_msg_id: str
+ ) -> None:
+ """Register a voice note as pending transcription (for /clear reply during transcription)."""
+ async with self._pending_voice_lock:
+ self._pending_voice[(chat_id, voice_msg_id)] = (voice_msg_id, status_msg_id)
+ self._pending_voice[(chat_id, status_msg_id)] = (
+ voice_msg_id,
+ status_msg_id,
+ )
+
+ async def cancel_pending_voice(
+ self, chat_id: str, reply_id: str
+ ) -> tuple[str, str] | None:
+ """Cancel a pending voice transcription. Returns (voice_msg_id, status_msg_id) if found."""
+ async with self._pending_voice_lock:
+ entry = self._pending_voice.pop((chat_id, reply_id), None)
+ if entry is None:
+ return None
+ voice_msg_id, status_msg_id = entry
+ self._pending_voice.pop((chat_id, voice_msg_id), None)
+ self._pending_voice.pop((chat_id, status_msg_id), None)
+ return (voice_msg_id, status_msg_id)
+
+ async def _is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
+ """Check if a voice note is still pending (not cancelled)."""
+ async with self._pending_voice_lock:
+ return (chat_id, voice_msg_id) in self._pending_voice
+
+ async def start(self) -> None:
+ """Initialize and connect to Telegram."""
+ if not self.bot_token:
+ raise ValueError("TELEGRAM_BOT_TOKEN is required")
+
+ # Configure request with longer timeouts
+ request = HTTPXRequest(
+ connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0
+ )
+
+ # Build Application
+ builder = Application.builder().token(self.bot_token).request(request)
+ self._application = builder.build()
+
+ # Register Internal Handlers
+ # We catch ALL text messages and commands to forward them
+ self._application.add_handler(
+ MessageHandler(filters.TEXT & (~filters.COMMAND), self._on_telegram_message)
+ )
+ self._application.add_handler(CommandHandler("start", self._on_start_command))
+ # Catch-all for other commands if needed, or let them fall through
+ self._application.add_handler(
+ MessageHandler(filters.COMMAND, self._on_telegram_message)
+ )
+ # Voice note handler
+ self._application.add_handler(
+ MessageHandler(filters.VOICE, self._on_telegram_voice)
+ )
+
+ # Initialize internal components with retry logic
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ await self._application.initialize()
+ await self._application.start()
+
+ # Start polling (non-blocking way for integration)
+ if self._application.updater:
+ await self._application.updater.start_polling(
+ drop_pending_updates=False
+ )
+
+ self._connected = True
+ break
+ except (NetworkError, Exception) as e:
+ if attempt < max_retries - 1:
+ wait_time = 2 * (attempt + 1)
+ logger.warning(
+ f"Connection failed (attempt {attempt + 1}/{max_retries}): {e}. Retrying in {wait_time}s..."
+ )
+ await asyncio.sleep(wait_time)
+ else:
+ logger.error(f"Failed to connect after {max_retries} attempts")
+ raise
+
+ # Initialize rate limiter
+ from ..limiter import MessagingRateLimiter
+
+ self._limiter = await MessagingRateLimiter.get_instance()
+
+ # Send startup notification
+ try:
+ target = self.allowed_user_id
+ if target:
+ startup_text = (
+ f"🚀 *{escape_md_v2('Claude Code Proxy is online!')}* "
+ f"{escape_md_v2('(Bot API)')}"
+ )
+ await self.send_message(
+ target,
+ startup_text,
+ )
+ except Exception as e:
+ logger.warning(f"Could not send startup message: {e}")
+
+ logger.info("Telegram platform started (Bot API)")
+
+ async def stop(self) -> None:
+ """Stop the bot."""
+ if self._application and self._application.updater:
+ await self._application.updater.stop()
+ await self._application.stop()
+ await self._application.shutdown()
+
+ self._connected = False
+ logger.info("Telegram platform stopped")
+
+ async def _with_retry(
+ self, func: Callable[..., Awaitable[Any]], *args, **kwargs
+ ) -> Any:
+ """Helper to execute a function with exponential backoff on network errors."""
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ return await func(*args, **kwargs)
+ except (TimeoutError, NetworkError) as e:
+ if "Message is not modified" in str(e):
+ return None
+ if attempt < max_retries - 1:
+ wait_time = 2**attempt # 1s, 2s, 4s
+ logger.warning(
+ f"Telegram API network error (attempt {attempt + 1}/{max_retries}): {e}. Retrying in {wait_time}s..."
+ )
+ await asyncio.sleep(wait_time)
+ else:
+ logger.error(
+ f"Telegram API failed after {max_retries} attempts: {e}"
+ )
+ raise
+ except RetryAfter as e:
+ # Telegram explicitly tells us to wait (PTB_TIMEDELTA: retry_after is timedelta)
+ from datetime import timedelta
+
+ retry_after = e.retry_after
+ if isinstance(retry_after, timedelta):
+ wait_secs = retry_after.total_seconds()
+ else:
+ wait_secs = float(retry_after)
+
+ logger.warning(f"Rate limited by Telegram, waiting {wait_secs}s...")
+ await asyncio.sleep(wait_secs)
+ # We don't increment attempt here, as this is a specific instruction
+ return await func(*args, **kwargs)
+ except TelegramError as e:
+ # Non-network Telegram errors
+ err_lower = str(e).lower()
+ if "message is not modified" in err_lower:
+ return None
+ # Best-effort no-op cases (common during chat cleanup / /clear).
+ if any(
+ x in err_lower
+ for x in [
+ "message to edit not found",
+ "message to delete not found",
+ "message can't be deleted",
+ "message can't be edited",
+ "not enough rights to delete",
+ ]
+ ):
+ return None
+ if "Can't parse entities" in str(e) and kwargs.get("parse_mode"):
+ logger.warning("Markdown failed, retrying without parse_mode")
+ kwargs["parse_mode"] = None
+ return await func(*args, **kwargs)
+ raise
+
+ async def send_message(
+ self,
+ chat_id: str,
+ text: str,
+ reply_to: str | None = None,
+ parse_mode: str | None = "MarkdownV2",
+ message_thread_id: str | None = None,
+ ) -> str:
+ """Send a message to a chat."""
+ app = self._application
+ if not app or not app.bot:
+ raise RuntimeError("Telegram application or bot not initialized")
+
+ async def _do_send(parse_mode=parse_mode):
+ bot = app.bot
+ kwargs: dict[str, Any] = {
+ "chat_id": chat_id,
+ "text": text,
+ "reply_to_message_id": int(reply_to) if reply_to else None,
+ "parse_mode": parse_mode,
+ }
+ if message_thread_id is not None:
+ kwargs["message_thread_id"] = int(message_thread_id)
+ msg = await bot.send_message(**kwargs)
+ return str(msg.message_id)
+
+ return await self._with_retry(_do_send, parse_mode=parse_mode)
+
+ async def edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ text: str,
+ parse_mode: str | None = "MarkdownV2",
+ ) -> None:
+ """Edit an existing message."""
+ app = self._application
+ if not app or not app.bot:
+ raise RuntimeError("Telegram application or bot not initialized")
+
+ async def _do_edit(parse_mode=parse_mode):
+ bot = app.bot
+ await bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=int(message_id),
+ text=text,
+ parse_mode=parse_mode,
+ )
+
+ await self._with_retry(_do_edit, parse_mode=parse_mode)
+
+ async def delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ ) -> None:
+ """Delete a message from a chat."""
+ app = self._application
+ if not app or not app.bot:
+ raise RuntimeError("Telegram application or bot not initialized")
+
+ async def _do_delete():
+ bot = app.bot
+ await bot.delete_message(chat_id=chat_id, message_id=int(message_id))
+
+ await self._with_retry(_do_delete)
+
+ async def delete_messages(self, chat_id: str, message_ids: list[str]) -> None:
+ """Delete multiple messages (best-effort)."""
+ if not message_ids:
+ return
+ app = self._application
+ if not app or not app.bot:
+ raise RuntimeError("Telegram application or bot not initialized")
+
+ # PTB supports bulk deletion via delete_messages; fall back to per-message.
+ bot = app.bot
+ if hasattr(bot, "delete_messages"):
+
+ async def _do_bulk():
+ mids = []
+ for mid in message_ids:
+ try:
+ mids.append(int(mid))
+ except Exception:
+ continue
+ if not mids:
+ return None
+ # delete_messages accepts a sequence of ints (up to 100).
+ await bot.delete_messages(chat_id=chat_id, message_ids=mids)
+
+ await self._with_retry(_do_bulk)
+ return
+
+ for mid in message_ids:
+ await self.delete_message(chat_id, mid)
+
+ async def queue_send_message(
+ self,
+ chat_id: str,
+ text: str,
+ reply_to: str | None = None,
+ parse_mode: str | None = "MarkdownV2",
+ fire_and_forget: bool = True,
+ message_thread_id: str | None = None,
+ ) -> str | None:
+ """Enqueue a message to be sent (using limiter)."""
+ # Note: Bot API handles limits better, but we still use our limiter for nice queuing
+ if not self._limiter:
+ return await self.send_message(
+ chat_id, text, reply_to, parse_mode, message_thread_id
+ )
+
+ async def _send():
+ return await self.send_message(
+ chat_id, text, reply_to, parse_mode, message_thread_id
+ )
+
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_send)
+ return None
+ else:
+ return await self._limiter.enqueue(_send)
+
+ async def queue_edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ text: str,
+ parse_mode: str | None = "MarkdownV2",
+ fire_and_forget: bool = True,
+ ) -> None:
+ """Enqueue a message edit."""
+ if not self._limiter:
+ return await self.edit_message(chat_id, message_id, text, parse_mode)
+
+ async def _edit():
+ return await self.edit_message(chat_id, message_id, text, parse_mode)
+
+ dedup_key = f"edit:{chat_id}:{message_id}"
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_edit, dedup_key=dedup_key)
+ else:
+ await self._limiter.enqueue(_edit, dedup_key=dedup_key)
+
+ async def queue_delete_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ fire_and_forget: bool = True,
+ ) -> None:
+ """Enqueue a message delete."""
+ if not self._limiter:
+ return await self.delete_message(chat_id, message_id)
+
+ async def _delete():
+ return await self.delete_message(chat_id, message_id)
+
+ dedup_key = f"del:{chat_id}:{message_id}"
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_delete, dedup_key=dedup_key)
+ else:
+ await self._limiter.enqueue(_delete, dedup_key=dedup_key)
+
+ async def queue_delete_messages(
+ self,
+ chat_id: str,
+ message_ids: list[str],
+ fire_and_forget: bool = True,
+ ) -> None:
+ """Enqueue a bulk delete (if supported) or a sequence of deletes."""
+ if not message_ids:
+ return
+
+ if not self._limiter:
+ return await self.delete_messages(chat_id, message_ids)
+
+ async def _bulk():
+ return await self.delete_messages(chat_id, message_ids)
+
+ # Dedup by the chunk content; okay to be coarse here.
+ dedup_key = f"del_bulk:{chat_id}:{hash(tuple(message_ids))}"
+ if fire_and_forget:
+ self._limiter.fire_and_forget(_bulk, dedup_key=dedup_key)
+ else:
+ await self._limiter.enqueue(_bulk, dedup_key=dedup_key)
+
+ def fire_and_forget(self, task: Awaitable[Any]) -> None:
+ """Execute a coroutine without awaiting it."""
+ if asyncio.iscoroutine(task):
+ _ = asyncio.create_task(task)
+ else:
+ _ = asyncio.ensure_future(task)
+
+ def on_message(
+ self,
+ handler: Callable[[IncomingMessage], Awaitable[None]],
+ ) -> None:
+ """Register a message handler callback."""
+ self._message_handler = handler
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if connected."""
+ return self._connected
+
+ async def _on_start_command(
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE
+ ) -> None:
+ """Handle /start command."""
+ if update.message:
+ await update.message.reply_text("👋 Hello! I am the Claude Code Proxy Bot.")
+ # We can also treat this as a message if we want it to trigger something
+ await self._on_telegram_message(update, context)
+
+ async def _on_telegram_message(
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE
+ ) -> None:
+ """Handle incoming updates."""
+ if (
+ not update.message
+ or not update.message.text
+ or not update.effective_user
+ or not update.effective_chat
+ ):
+ return
+
+ user_id = str(update.effective_user.id)
+ chat_id = str(update.effective_chat.id)
+
+ # Security check
+ if self.allowed_user_id and user_id != str(self.allowed_user_id).strip():
+ logger.warning(f"Unauthorized access attempt from {user_id}")
+ return
+
+ message_id = str(update.message.message_id)
+ reply_to = (
+ str(update.message.reply_to_message.message_id)
+ if update.message.reply_to_message
+ else None
+ )
+ thread_id = (
+ str(update.message.message_thread_id)
+ if getattr(update.message, "message_thread_id", None) is not None
+ else None
+ )
+ text_preview = (update.message.text or "")[:80]
+ if len(update.message.text or "") > 80:
+ text_preview += "..."
+ logger.info(
+ "TELEGRAM_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}",
+ chat_id,
+ message_id,
+ reply_to,
+ text_preview,
+ )
+
+ if not self._message_handler:
+ return
+
+ incoming = IncomingMessage(
+ text=update.message.text,
+ chat_id=chat_id,
+ user_id=user_id,
+ message_id=message_id,
+ platform="telegram",
+ reply_to_message_id=reply_to,
+ message_thread_id=thread_id,
+ raw_event=update,
+ )
+
+ try:
+ await self._message_handler(incoming)
+ except Exception as e:
+ logger.error(f"Error handling message: {e}")
+ with contextlib.suppress(Exception):
+ await self.send_message(
+ chat_id,
+ f"❌ *{escape_md_v2('Error:')}* {escape_md_v2(get_user_facing_error_message(e)[:200])}",
+ reply_to=incoming.message_id,
+ message_thread_id=thread_id,
+ parse_mode="MarkdownV2",
+ )
+
+ async def _on_telegram_voice(
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE
+ ) -> None:
+ """Handle incoming voice messages."""
+ if (
+ not update.message
+ or not update.message.voice
+ or not update.effective_user
+ or not update.effective_chat
+ ):
+ return
+
+ from config.settings import get_settings
+
+ settings = get_settings()
+ if not settings.voice_note_enabled:
+ await update.message.reply_text("Voice notes are disabled.")
+ return
+
+ user_id = str(update.effective_user.id)
+ chat_id = str(update.effective_chat.id)
+
+ if self.allowed_user_id and user_id != str(self.allowed_user_id).strip():
+ logger.warning(f"Unauthorized voice access attempt from {user_id}")
+ return
+
+ if not self._message_handler:
+ return
+
+ thread_id = (
+ str(update.message.message_thread_id)
+ if getattr(update.message, "message_thread_id", None) is not None
+ else None
+ )
+ status_msg_id = await self.queue_send_message(
+ chat_id,
+ format_status("⏳", "Transcribing voice note..."),
+ reply_to=str(update.message.message_id),
+ parse_mode="MarkdownV2",
+ fire_and_forget=False,
+ message_thread_id=thread_id,
+ )
+
+ message_id = str(update.message.message_id)
+ await self._register_pending_voice(chat_id, message_id, str(status_msg_id))
+ reply_to = (
+ str(update.message.reply_to_message.message_id)
+ if update.message.reply_to_message
+ else None
+ )
+
+ voice = update.message.voice
+ suffix = ".ogg"
+ if voice.mime_type and "mpeg" in voice.mime_type:
+ suffix = ".mp3"
+ elif voice.mime_type and "mp4" in voice.mime_type:
+ suffix = ".mp4"
+
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
+ tmp_path = Path(tmp.name)
+
+ try:
+ tg_file = await context.bot.get_file(voice.file_id)
+ await tg_file.download_to_drive(custom_path=str(tmp_path))
+
+ from ..transcription import transcribe_audio
+
+ transcribed = await asyncio.to_thread(
+ transcribe_audio,
+ tmp_path,
+ voice.mime_type or "audio/ogg",
+ whisper_model=settings.whisper_model,
+ whisper_device=settings.whisper_device,
+ )
+
+ if not await self._is_voice_still_pending(chat_id, message_id):
+ await self.queue_delete_message(chat_id, str(status_msg_id))
+ return
+
+ async with self._pending_voice_lock:
+ self._pending_voice.pop((chat_id, message_id), None)
+ self._pending_voice.pop((chat_id, str(status_msg_id)), None)
+
+ incoming = IncomingMessage(
+ text=transcribed,
+ chat_id=chat_id,
+ user_id=user_id,
+ message_id=message_id,
+ platform="telegram",
+ reply_to_message_id=reply_to,
+ message_thread_id=thread_id,
+ raw_event=update,
+ status_message_id=status_msg_id,
+ )
+
+ logger.info(
+ "TELEGRAM_VOICE: chat_id={} message_id={} transcribed={!r}",
+ chat_id,
+ message_id,
+ (transcribed[:80] + "..." if len(transcribed) > 80 else transcribed),
+ )
+
+ await self._message_handler(incoming)
+ except ValueError as e:
+ await update.message.reply_text(get_user_facing_error_message(e)[:200])
+ except ImportError as e:
+ await update.message.reply_text(get_user_facing_error_message(e)[:200])
+ except Exception as e:
+ logger.error(f"Voice transcription failed: {e}")
+ await update.message.reply_text(
+ "Could not transcribe voice note. Please try again or send text."
+ )
+ finally:
+ with contextlib.suppress(OSError):
+ tmp_path.unlink(missing_ok=True)
diff --git a/Claude_Code/messaging/rendering/__init__.py b/Claude_Code/messaging/rendering/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c0528e2bb0edb83b4598096a0152fec42b4e049
--- /dev/null
+++ b/Claude_Code/messaging/rendering/__init__.py
@@ -0,0 +1,41 @@
+"""Markdown rendering utilities for messaging platforms."""
+
+from .discord_markdown import (
+ discord_bold,
+ discord_code_inline,
+ escape_discord,
+ escape_discord_code,
+ format_status_discord,
+ render_markdown_to_discord,
+)
+from .discord_markdown import (
+ format_status as format_status_discord_fn,
+)
+from .telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ escape_md_v2_link_url,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+)
+from .telegram_markdown import (
+ format_status as format_status_telegram_fn,
+)
+
+__all__ = [
+ "discord_bold",
+ "discord_code_inline",
+ "escape_discord",
+ "escape_discord_code",
+ "escape_md_v2",
+ "escape_md_v2_code",
+ "escape_md_v2_link_url",
+ "format_status_discord",
+ "format_status_discord_fn",
+ "format_status_telegram_fn",
+ "mdv2_bold",
+ "mdv2_code_inline",
+ "render_markdown_to_discord",
+ "render_markdown_to_mdv2",
+]
diff --git a/Claude_Code/messaging/rendering/discord_markdown.py b/Claude_Code/messaging/rendering/discord_markdown.py
new file mode 100644
index 0000000000000000000000000000000000000000..b684f288f5a7562fc337a060f08c7fce027bfd04
--- /dev/null
+++ b/Claude_Code/messaging/rendering/discord_markdown.py
@@ -0,0 +1,365 @@
+"""Discord markdown utilities.
+
+Discord uses standard markdown: **bold**, *italic*, `code`, ```code block```.
+Used by the message handler and Discord platform adapter.
+"""
+
+import re
+
+from markdown_it import MarkdownIt
+
+# Discord escapes: \ * _ ` ~ | >
+DISCORD_SPECIAL = set("\\*_`~|>")
+
+_MD = MarkdownIt("commonmark", {"html": False, "breaks": False})
+_MD.enable("strikethrough")
+_MD.enable("table")
+
+_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
+_FENCE_RE = re.compile(r"^\s*```")
+
+
+def _is_gfm_table_header_line(line: str) -> bool:
+ """Check if line is a GFM table header."""
+ if "|" not in line:
+ return False
+ if _TABLE_SEP_RE.match(line):
+ return False
+ stripped = line.strip()
+ parts = [p.strip() for p in stripped.strip("|").split("|")]
+ parts = [p for p in parts if p != ""]
+ return len(parts) >= 2
+
+
+def _normalize_gfm_tables(text: str) -> str:
+ """Insert blank line before detected tables outside code blocks."""
+ lines = text.splitlines()
+ if len(lines) < 2:
+ return text
+
+ out_lines: list[str] = []
+ in_fence = False
+
+ for idx, line in enumerate(lines):
+ if _FENCE_RE.match(line):
+ in_fence = not in_fence
+ out_lines.append(line)
+ continue
+
+ if (
+ not in_fence
+ and idx + 1 < len(lines)
+ and _is_gfm_table_header_line(line)
+ and _TABLE_SEP_RE.match(lines[idx + 1])
+ and out_lines
+ and out_lines[-1].strip() != ""
+ ):
+ m = re.match(r"^(\s*)", line)
+ indent = m.group(1) if m else ""
+ out_lines.append(indent)
+
+ out_lines.append(line)
+
+ return "\n".join(out_lines)
+
+
+def escape_discord(text: str) -> str:
+ """Escape text for Discord markdown (bold, italic, etc.)."""
+ return "".join(f"\\{ch}" if ch in DISCORD_SPECIAL else ch for ch in text)
+
+
+def escape_discord_code(text: str) -> str:
+ """Escape text for Discord code spans/blocks."""
+ return text.replace("\\", "\\\\").replace("`", "\\`")
+
+
+def discord_bold(text: str) -> str:
+ """Format text as bold in Discord (uses **)."""
+ return f"**{escape_discord(text)}**"
+
+
+def discord_code_inline(text: str) -> str:
+ """Format text as inline code in Discord."""
+ return f"`{escape_discord_code(text)}`"
+
+
+def format_status_discord(label: str, suffix: str | None = None) -> str:
+ """Format a status message for Discord (label in bold, optional suffix)."""
+ base = discord_bold(label)
+ if suffix:
+ return f"{base} {escape_discord(suffix)}"
+ return base
+
+
+def format_status(emoji: str, label: str, suffix: str | None = None) -> str:
+ """Format a status message with emoji for Discord (matches Telegram API)."""
+ base = f"{emoji} {discord_bold(label)}"
+ if suffix:
+ return f"{base} {escape_discord(suffix)}"
+ return base
+
+
+def render_markdown_to_discord(text: str) -> str:
+ """Render common Markdown into Discord-compatible format."""
+ if not text:
+ return ""
+
+ text = _normalize_gfm_tables(text)
+ tokens = _MD.parse(text)
+
+ def render_inline_table_plain(children) -> str:
+ out: list[str] = []
+ for tok in children:
+ if tok.type == "text" or tok.type == "code_inline":
+ out.append(tok.content)
+ elif tok.type in {"softbreak", "hardbreak"}:
+ out.append(" ")
+ elif tok.type == "image" and tok.content:
+ out.append(tok.content)
+ return "".join(out)
+
+ def render_inline(children) -> str:
+ out: list[str] = []
+ i = 0
+ while i < len(children):
+ tok = children[i]
+ t = tok.type
+ if t == "text":
+ out.append(escape_discord(tok.content))
+ elif t in {"softbreak", "hardbreak"}:
+ out.append("\n")
+ elif t == "em_open" or t == "em_close":
+ out.append("*")
+ elif t == "strong_open" or t == "strong_close":
+ out.append("**")
+ elif t == "s_open" or t == "s_close":
+ out.append("~~")
+ elif t == "code_inline":
+ out.append(f"`{escape_discord_code(tok.content)}`")
+ elif t == "link_open":
+ href = ""
+ if tok.attrs:
+ if isinstance(tok.attrs, dict):
+ href = tok.attrs.get("href", "")
+ else:
+ for key, val in tok.attrs:
+ if key == "href":
+ href = val
+ break
+ inner_tokens = []
+ i += 1
+ while i < len(children) and children[i].type != "link_close":
+ inner_tokens.append(children[i])
+ i += 1
+ link_text = ""
+ for child in inner_tokens:
+ if child.type == "text" or child.type == "code_inline":
+ link_text += child.content
+ out.append(f"[{escape_discord(link_text)}]({href})")
+ elif t == "image":
+ href = ""
+ alt = tok.content or ""
+ if tok.attrs:
+ if isinstance(tok.attrs, dict):
+ href = tok.attrs.get("src", "")
+ else:
+ for key, val in tok.attrs:
+ if key == "src":
+ href = val
+ break
+ if alt:
+ out.append(f"{escape_discord(alt)} ({href})")
+ else:
+ out.append(href)
+ else:
+ out.append(escape_discord(tok.content or ""))
+ i += 1
+ return "".join(out)
+
+ out: list[str] = []
+ list_stack: list[dict] = []
+ pending_prefix: str | None = None
+ blockquote_level = 0
+ in_heading = False
+
+ def apply_blockquote(val: str) -> str:
+ if blockquote_level <= 0:
+ return val
+ prefix = "> " * blockquote_level
+ return prefix + val.replace("\n", "\n" + prefix)
+
+ i = 0
+ while i < len(tokens):
+ tok = tokens[i]
+ t = tok.type
+ if t == "paragraph_open":
+ pass
+ elif t == "paragraph_close":
+ out.append("\n")
+ elif t == "heading_open":
+ in_heading = True
+ elif t == "heading_close":
+ in_heading = False
+ out.append("\n")
+ elif t == "bullet_list_open":
+ list_stack.append({"type": "bullet", "index": 1})
+ elif t == "bullet_list_close":
+ if list_stack:
+ list_stack.pop()
+ out.append("\n")
+ elif t == "ordered_list_open":
+ start = 1
+ if tok.attrs:
+ if isinstance(tok.attrs, dict):
+ val = tok.attrs.get("start")
+ if val is not None:
+ try:
+ start = int(val)
+ except TypeError, ValueError:
+ start = 1
+ else:
+ for key, val in tok.attrs:
+ if key == "start":
+ try:
+ start = int(val)
+ except TypeError, ValueError:
+ start = 1
+ break
+ list_stack.append({"type": "ordered", "index": start})
+ elif t == "ordered_list_close":
+ if list_stack:
+ list_stack.pop()
+ out.append("\n")
+ elif t == "list_item_open":
+ if list_stack:
+ top = list_stack[-1]
+ if top["type"] == "bullet":
+ pending_prefix = "- "
+ else:
+ pending_prefix = f"{top['index']}. "
+ top["index"] += 1
+ elif t == "list_item_close":
+ out.append("\n")
+ elif t == "blockquote_open":
+ blockquote_level += 1
+ elif t == "blockquote_close":
+ blockquote_level = max(0, blockquote_level - 1)
+ out.append("\n")
+ elif t == "table_open":
+ if pending_prefix:
+ out.append(apply_blockquote(pending_prefix.rstrip()))
+ out.append("\n")
+ pending_prefix = None
+
+ rows: list[list[str]] = []
+ row_is_header: list[bool] = []
+
+ j = i + 1
+ in_thead = False
+ in_row = False
+ current_row: list[str] = []
+ current_row_header = False
+
+ in_cell = False
+ cell_parts: list[str] = []
+
+ while j < len(tokens):
+ tt = tokens[j].type
+ if tt == "thead_open":
+ in_thead = True
+ elif tt == "thead_close":
+ in_thead = False
+ elif tt == "tr_open":
+ in_row = True
+ current_row = []
+ current_row_header = in_thead
+ elif tt in {"th_open", "td_open"}:
+ in_cell = True
+ cell_parts = []
+ elif tt == "inline" and in_cell:
+ cell_parts.append(
+ render_inline_table_plain(tokens[j].children or [])
+ )
+ elif tt in {"th_close", "td_close"} and in_cell:
+ cell = " ".join(cell_parts).strip()
+ current_row.append(cell)
+ in_cell = False
+ cell_parts = []
+ elif tt == "tr_close" and in_row:
+ rows.append(current_row)
+ row_is_header.append(bool(current_row_header))
+ in_row = False
+ elif tt == "table_close":
+ break
+ j += 1
+
+ if rows:
+ col_count = max((len(r) for r in rows), default=0)
+ norm_rows: list[list[str]] = []
+ for r in rows:
+ if len(r) < col_count:
+ r = r + [""] * (col_count - len(r))
+ norm_rows.append(r)
+
+ widths: list[int] = []
+ for c in range(col_count):
+ w = max((len(r[c]) for r in norm_rows), default=0)
+ widths.append(max(w, 3))
+
+ def fmt_row(
+ r: list[str], _w: list[int] = widths, _c: int = col_count
+ ) -> str:
+ cells = [r[c].ljust(_w[c]) for c in range(_c)]
+ return "| " + " | ".join(cells) + " |"
+
+ def fmt_sep(_w: list[int] = widths, _c: int = col_count) -> str:
+ cells = ["-" * _w[c] for c in range(_c)]
+ return "| " + " | ".join(cells) + " |"
+
+ last_header_idx = -1
+ for idx, is_h in enumerate(row_is_header):
+ if is_h:
+ last_header_idx = idx
+
+ lines: list[str] = []
+ for idx, r in enumerate(norm_rows):
+ lines.append(fmt_row(r))
+ if idx == last_header_idx:
+ lines.append(fmt_sep())
+
+ table_text = "\n".join(lines).rstrip()
+ out.append(f"```\n{escape_discord_code(table_text)}\n```")
+ out.append("\n")
+
+ i = j + 1
+ continue
+ elif t in {"code_block", "fence"}:
+ code = escape_discord_code(tok.content.rstrip("\n"))
+ out.append(f"```\n{code}\n```")
+ out.append("\n")
+ elif t == "inline":
+ rendered = render_inline(tok.children or [])
+ if in_heading:
+ rendered = f"**{render_inline(tok.children or [])}**"
+ if pending_prefix:
+ rendered = pending_prefix + rendered
+ pending_prefix = None
+ rendered = apply_blockquote(rendered)
+ out.append(rendered)
+ else:
+ if tok.content:
+ out.append(escape_discord(tok.content))
+ i += 1
+
+ return "".join(out).rstrip()
+
+
+__all__ = [
+ "discord_bold",
+ "discord_code_inline",
+ "escape_discord",
+ "escape_discord_code",
+ "format_status",
+ "format_status_discord",
+ "render_markdown_to_discord",
+]
diff --git a/Claude_Code/messaging/rendering/telegram_markdown.py b/Claude_Code/messaging/rendering/telegram_markdown.py
new file mode 100644
index 0000000000000000000000000000000000000000..093c3089ed25a3cd919f976b393406ff7e568353
--- /dev/null
+++ b/Claude_Code/messaging/rendering/telegram_markdown.py
@@ -0,0 +1,380 @@
+"""Telegram MarkdownV2 utilities.
+
+Renders common Markdown into Telegram MarkdownV2 format.
+Used by the message handler and Telegram platform adapter.
+"""
+
+import re
+
+from markdown_it import MarkdownIt
+
+MDV2_SPECIAL_CHARS = set("\\_*[]()~`>#+-=|{}.!")
+MDV2_LINK_ESCAPE = set("\\)")
+
+_MD = MarkdownIt("commonmark", {"html": False, "breaks": False})
+_MD.enable("strikethrough")
+_MD.enable("table")
+
+_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
+_FENCE_RE = re.compile(r"^\s*```")
+
+
+def _is_gfm_table_header_line(line: str) -> bool:
+ """Check if line is a GFM table header (pipe-delimited, not separator)."""
+ if "|" not in line:
+ return False
+ if _TABLE_SEP_RE.match(line):
+ return False
+ stripped = line.strip()
+ parts = [p.strip() for p in stripped.strip("|").split("|")]
+ parts = [p for p in parts if p != ""]
+ return len(parts) >= 2
+
+
+def _normalize_gfm_tables(text: str) -> str:
+ """
+ Many LLMs emit tables immediately after a paragraph line (no blank line).
+ Markdown-it will treat that as a softbreak within the paragraph, so the
+ table extension won't trigger. Insert a blank line before detected tables.
+
+ We only do this outside fenced code blocks.
+ """
+ lines = text.splitlines()
+ if len(lines) < 2:
+ return text
+
+ out_lines: list[str] = []
+ in_fence = False
+
+ for idx, line in enumerate(lines):
+ if _FENCE_RE.match(line):
+ in_fence = not in_fence
+ out_lines.append(line)
+ continue
+
+ if (
+ not in_fence
+ and idx + 1 < len(lines)
+ and _is_gfm_table_header_line(line)
+ and _TABLE_SEP_RE.match(lines[idx + 1])
+ and out_lines
+ and out_lines[-1].strip() != ""
+ ):
+ m = re.match(r"^(\s*)", line)
+ indent = m.group(1) if m else ""
+ out_lines.append(indent)
+
+ out_lines.append(line)
+
+ return "\n".join(out_lines)
+
+
+def escape_md_v2(text: str) -> str:
+ """Escape text for Telegram MarkdownV2."""
+ return "".join(f"\\{ch}" if ch in MDV2_SPECIAL_CHARS else ch for ch in text)
+
+
+def escape_md_v2_code(text: str) -> str:
+ """Escape text for Telegram MarkdownV2 code spans/blocks."""
+ return text.replace("\\", "\\\\").replace("`", "\\`")
+
+
+def escape_md_v2_link_url(text: str) -> str:
+ """Escape URL for Telegram MarkdownV2 link destination."""
+ return "".join(f"\\{ch}" if ch in MDV2_LINK_ESCAPE else ch for ch in text)
+
+
+def mdv2_bold(text: str) -> str:
+ """Format text as bold in MarkdownV2."""
+ return f"*{escape_md_v2(text)}*"
+
+
+def mdv2_code_inline(text: str) -> str:
+ """Format text as inline code in MarkdownV2."""
+ return f"`{escape_md_v2_code(text)}`"
+
+
+def format_status(emoji: str, label: str, suffix: str | None = None) -> str:
+ """Format a status message with emoji and optional suffix."""
+ base = f"{emoji} {mdv2_bold(label)}"
+ if suffix:
+ return f"{base} {escape_md_v2(suffix)}"
+ return base
+
+
+def render_markdown_to_mdv2(text: str) -> str:
+ """Render common Markdown into Telegram MarkdownV2."""
+ if not text:
+ return ""
+
+ text = _normalize_gfm_tables(text)
+ tokens = _MD.parse(text)
+
+ def render_inline_table_plain(children) -> str:
+ out: list[str] = []
+ for tok in children:
+ if tok.type == "text" or tok.type == "code_inline":
+ out.append(tok.content)
+ elif tok.type in {"softbreak", "hardbreak"}:
+ out.append(" ")
+ elif tok.type == "image" and tok.content:
+ out.append(tok.content)
+ return "".join(out)
+
+ def render_inline_plain(children) -> str:
+ out: list[str] = []
+ for tok in children:
+ if tok.type == "text" or tok.type == "code_inline":
+ out.append(escape_md_v2(tok.content))
+ elif tok.type in {"softbreak", "hardbreak"}:
+ out.append("\n")
+ return "".join(out)
+
+ def render_inline(children) -> str:
+ out: list[str] = []
+ i = 0
+ while i < len(children):
+ tok = children[i]
+ t = tok.type
+ if t == "text":
+ out.append(escape_md_v2(tok.content))
+ elif t in {"softbreak", "hardbreak"}:
+ out.append("\n")
+ elif t == "em_open" or t == "em_close":
+ out.append("_")
+ elif t == "strong_open" or t == "strong_close":
+ out.append("*")
+ elif t == "s_open" or t == "s_close":
+ out.append("~")
+ elif t == "code_inline":
+ out.append(f"`{escape_md_v2_code(tok.content)}`")
+ elif t == "link_open":
+ href = ""
+ if tok.attrs:
+ if isinstance(tok.attrs, dict):
+ href = tok.attrs.get("href", "")
+ else:
+ for key, val in tok.attrs:
+ if key == "href":
+ href = val
+ break
+ inner_tokens = []
+ i += 1
+ while i < len(children) and children[i].type != "link_close":
+ inner_tokens.append(children[i])
+ i += 1
+ link_text = ""
+ for child in inner_tokens:
+ if child.type == "text" or child.type == "code_inline":
+ link_text += child.content
+ out.append(
+ f"[{escape_md_v2(link_text)}]({escape_md_v2_link_url(href)})"
+ )
+ elif t == "image":
+ href = ""
+ alt = tok.content or ""
+ if tok.attrs:
+ if isinstance(tok.attrs, dict):
+ href = tok.attrs.get("src", "")
+ else:
+ for key, val in tok.attrs:
+ if key == "src":
+ href = val
+ break
+ if alt:
+ out.append(f"{escape_md_v2(alt)} ({escape_md_v2_link_url(href)})")
+ else:
+ out.append(escape_md_v2_link_url(href))
+ else:
+ out.append(escape_md_v2(tok.content or ""))
+ i += 1
+ return "".join(out)
+
+ out: list[str] = []
+ list_stack: list[dict] = []
+ pending_prefix: str | None = None
+ blockquote_level = 0
+ in_heading = False
+
+ def apply_blockquote(val: str) -> str:
+ if blockquote_level <= 0:
+ return val
+ prefix = "> " * blockquote_level
+ return prefix + val.replace("\n", "\n" + prefix)
+
+ i = 0
+ while i < len(tokens):
+ tok = tokens[i]
+ t = tok.type
+ if t == "paragraph_open":
+ pass
+ elif t == "paragraph_close":
+ out.append("\n")
+ elif t == "heading_open":
+ in_heading = True
+ elif t == "heading_close":
+ in_heading = False
+ out.append("\n")
+ elif t == "bullet_list_open":
+ list_stack.append({"type": "bullet", "index": 1})
+ elif t == "bullet_list_close":
+ if list_stack:
+ list_stack.pop()
+ out.append("\n")
+ elif t == "ordered_list_open":
+ start = 1
+ if tok.attrs:
+ if isinstance(tok.attrs, dict):
+ val = tok.attrs.get("start")
+ if val is not None:
+ try:
+ start = int(val)
+ except TypeError, ValueError:
+ start = 1
+ else:
+ for key, val in tok.attrs:
+ if key == "start":
+ try:
+ start = int(val)
+ except TypeError, ValueError:
+ start = 1
+ break
+ list_stack.append({"type": "ordered", "index": start})
+ elif t == "ordered_list_close":
+ if list_stack:
+ list_stack.pop()
+ out.append("\n")
+ elif t == "list_item_open":
+ if list_stack:
+ top = list_stack[-1]
+ if top["type"] == "bullet":
+ pending_prefix = "\\- "
+ else:
+ pending_prefix = f"{top['index']}\\."
+ top["index"] += 1
+ pending_prefix += " "
+ elif t == "list_item_close":
+ out.append("\n")
+ elif t == "blockquote_open":
+ blockquote_level += 1
+ elif t == "blockquote_close":
+ blockquote_level = max(0, blockquote_level - 1)
+ out.append("\n")
+ elif t == "table_open":
+ if pending_prefix:
+ out.append(apply_blockquote(pending_prefix.rstrip()))
+ out.append("\n")
+ pending_prefix = None
+
+ rows: list[list[str]] = []
+ row_is_header: list[bool] = []
+
+ j = i + 1
+ in_thead = False
+ in_row = False
+ current_row: list[str] = []
+ current_row_header = False
+
+ in_cell = False
+ cell_parts: list[str] = []
+
+ while j < len(tokens):
+ tt = tokens[j].type
+ if tt == "thead_open":
+ in_thead = True
+ elif tt == "thead_close":
+ in_thead = False
+ elif tt == "tr_open":
+ in_row = True
+ current_row = []
+ current_row_header = in_thead
+ elif tt in {"th_open", "td_open"}:
+ in_cell = True
+ cell_parts = []
+ elif tt == "inline" and in_cell:
+ cell_parts.append(
+ render_inline_table_plain(tokens[j].children or [])
+ )
+ elif tt in {"th_close", "td_close"} and in_cell:
+ cell = " ".join(cell_parts).strip()
+ current_row.append(cell)
+ in_cell = False
+ cell_parts = []
+ elif tt == "tr_close" and in_row:
+ rows.append(current_row)
+ row_is_header.append(bool(current_row_header))
+ in_row = False
+ elif tt == "table_close":
+ break
+ j += 1
+
+ if rows:
+ col_count = max((len(r) for r in rows), default=0)
+ norm_rows: list[list[str]] = []
+ for r in rows:
+ if len(r) < col_count:
+ r = r + [""] * (col_count - len(r))
+ norm_rows.append(r)
+
+ widths: list[int] = []
+ for c in range(col_count):
+ w = max((len(r[c]) for r in norm_rows), default=0)
+ widths.append(max(w, 3))
+
+ def fmt_row(
+ r: list[str], _w: list[int] = widths, _c: int = col_count
+ ) -> str:
+ cells = [r[c].ljust(_w[c]) for c in range(_c)]
+ return "| " + " | ".join(cells) + " |"
+
+ def fmt_sep(_w: list[int] = widths, _c: int = col_count) -> str:
+ cells = ["-" * _w[c] for c in range(_c)]
+ return "| " + " | ".join(cells) + " |"
+
+ last_header_idx = -1
+ for idx, is_h in enumerate(row_is_header):
+ if is_h:
+ last_header_idx = idx
+
+ lines: list[str] = []
+ for idx, r in enumerate(norm_rows):
+ lines.append(fmt_row(r))
+ if idx == last_header_idx:
+ lines.append(fmt_sep())
+
+ table_text = "\n".join(lines).rstrip()
+ out.append(f"```\n{escape_md_v2_code(table_text)}\n```")
+ out.append("\n")
+
+ i = j + 1
+ continue
+ elif t in {"code_block", "fence"}:
+ code = escape_md_v2_code(tok.content.rstrip("\n"))
+ out.append(f"```\n{code}\n```")
+ out.append("\n")
+ elif t == "inline":
+ rendered = render_inline(tok.children or [])
+ if in_heading:
+ rendered = f"*{render_inline_plain(tok.children or [])}*"
+ if pending_prefix:
+ rendered = pending_prefix + rendered
+ pending_prefix = None
+ rendered = apply_blockquote(rendered)
+ out.append(rendered)
+ else:
+ if tok.content:
+ out.append(escape_md_v2(tok.content))
+ i += 1
+
+ return "".join(out).rstrip()
+
+
+__all__ = [
+ "escape_md_v2",
+ "escape_md_v2_code",
+ "escape_md_v2_link_url",
+ "format_status",
+ "mdv2_bold",
+ "mdv2_code_inline",
+ "render_markdown_to_mdv2",
+]
diff --git a/Claude_Code/messaging/session.py b/Claude_Code/messaging/session.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4e4e9d4f4aa99f11623c727b8713aa2a47f3feb
--- /dev/null
+++ b/Claude_Code/messaging/session.py
@@ -0,0 +1,289 @@
+"""
+Session Store for Messaging Platforms
+
+Provides persistent storage for mapping platform messages to Claude CLI session IDs
+and message trees for conversation continuation.
+"""
+
+import json
+import os
+import threading
+from datetime import UTC, datetime
+from typing import Any
+
+from loguru import logger
+
+
+class SessionStore:
+ """
+ Persistent storage for message ↔ Claude session mappings and message trees.
+
+ Uses a JSON file for storage with thread-safe operations.
+ Platform-agnostic: works with any messaging platform.
+ """
+
+ def __init__(self, storage_path: str = "sessions.json"):
+ self.storage_path = storage_path
+ self._lock = threading.Lock()
+ self._trees: dict[str, dict] = {} # root_id -> tree data
+ self._node_to_tree: dict[str, str] = {} # node_id -> root_id
+ # Per-chat message ID log used to support best-effort UI clearing (/clear).
+ # Key: "{platform}:{chat_id}" -> list of records
+ self._message_log: dict[str, list[dict[str, Any]]] = {}
+ self._message_log_ids: dict[str, set[str]] = {}
+ self._dirty = False
+ self._save_timer: threading.Timer | None = None
+ self._save_debounce_secs = 0.5
+ cap_raw = os.getenv("MAX_MESSAGE_LOG_ENTRIES_PER_CHAT", "").strip()
+ try:
+ self._message_log_cap: int | None = int(cap_raw) if cap_raw else None
+ except ValueError:
+ self._message_log_cap = None
+ self._load()
+
+ def _make_chat_key(self, platform: str, chat_id: str) -> str:
+ return f"{platform}:{chat_id}"
+
+ def _load(self) -> None:
+ """Load sessions and trees from disk."""
+ if not os.path.exists(self.storage_path):
+ return
+
+ try:
+ with open(self.storage_path, encoding="utf-8") as f:
+ data = json.load(f)
+
+ # Load trees
+ self._trees = data.get("trees", {})
+ self._node_to_tree = data.get("node_to_tree", {})
+
+ # Load message log (optional/backward compatible)
+ raw_log = data.get("message_log", {}) or {}
+ if isinstance(raw_log, dict):
+ self._message_log = {}
+ self._message_log_ids = {}
+ for chat_key, items in raw_log.items():
+ if not isinstance(chat_key, str) or not isinstance(items, list):
+ continue
+ cleaned: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for it in items:
+ if not isinstance(it, dict):
+ continue
+ mid = it.get("message_id")
+ if mid is None:
+ continue
+ mid_s = str(mid)
+ if mid_s in seen:
+ continue
+ seen.add(mid_s)
+ cleaned.append(
+ {
+ "message_id": mid_s,
+ "ts": str(it.get("ts") or ""),
+ "direction": str(it.get("direction") or ""),
+ "kind": str(it.get("kind") or ""),
+ }
+ )
+ self._message_log[chat_key] = cleaned
+ self._message_log_ids[chat_key] = seen
+
+ logger.info(
+ f"Loaded {len(self._trees)} trees and "
+ f"{sum(len(v) for v in self._message_log.values())} msg_ids from {self.storage_path}"
+ )
+ except Exception as e:
+ logger.error(f"Failed to load sessions: {e}")
+
+ def _snapshot(self) -> dict:
+ """Snapshot current state for serialization. Caller must hold self._lock."""
+ return {
+ "trees": dict(self._trees),
+ "node_to_tree": dict(self._node_to_tree),
+ "message_log": {k: list(v) for k, v in self._message_log.items()},
+ }
+
+ def _write_data(self, data: dict) -> None:
+ """Write data dict to disk. Must be called WITHOUT holding self._lock."""
+ with open(self.storage_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+
+ def _schedule_save(self) -> None:
+ """Schedule a debounced save. Caller must hold self._lock."""
+ self._dirty = True
+ if self._save_timer is not None:
+ self._save_timer.cancel()
+ self._save_timer = None
+ self._save_timer = threading.Timer(
+ self._save_debounce_secs, self._save_from_timer
+ )
+ self._save_timer.daemon = True
+ self._save_timer.start()
+
+ def _save_from_timer(self) -> None:
+ """Timer callback: save if dirty. Runs in timer thread."""
+ with self._lock:
+ if not self._dirty:
+ self._save_timer = None
+ return
+ snapshot = self._snapshot()
+ self._dirty = False
+ self._save_timer = None
+ try:
+ self._write_data(snapshot)
+ except Exception as e:
+ logger.error(f"Failed to save sessions: {e}")
+ with self._lock:
+ self._dirty = True
+
+ def _flush_save(self) -> dict:
+ """Cancel pending timer and snapshot current state. Caller must hold self._lock.
+ Returns snapshot dict; caller must call _write_data(snapshot) outside the lock."""
+ if self._save_timer is not None:
+ self._save_timer.cancel()
+ self._save_timer = None
+ self._dirty = False
+ return self._snapshot()
+
+ def flush_pending_save(self) -> None:
+ """Flush any pending debounced save. Call on shutdown to avoid losing data."""
+ with self._lock:
+ snapshot = self._flush_save()
+ try:
+ self._write_data(snapshot)
+ except Exception as e:
+ logger.error(f"Failed to save sessions: {e}")
+ with self._lock:
+ self._dirty = True
+
+ def record_message_id(
+ self,
+ platform: str,
+ chat_id: str,
+ message_id: str,
+ direction: str,
+ kind: str,
+ ) -> None:
+ """Record a message_id for later best-effort deletion (/clear)."""
+ if message_id is None:
+ return
+
+ chat_key = self._make_chat_key(str(platform), str(chat_id))
+ mid = str(message_id)
+
+ with self._lock:
+ seen = self._message_log_ids.setdefault(chat_key, set())
+ if mid in seen:
+ return
+
+ rec = {
+ "message_id": mid,
+ "ts": datetime.now(UTC).isoformat(),
+ "direction": str(direction),
+ "kind": str(kind),
+ }
+ self._message_log.setdefault(chat_key, []).append(rec)
+ seen.add(mid)
+
+ # Optional cap to prevent unbounded growth if configured.
+ if self._message_log_cap is not None and self._message_log_cap > 0:
+ items = self._message_log.get(chat_key, [])
+ if len(items) > self._message_log_cap:
+ self._message_log[chat_key] = items[-self._message_log_cap :]
+ self._message_log_ids[chat_key] = {
+ str(x.get("message_id")) for x in self._message_log[chat_key]
+ }
+
+ self._schedule_save()
+
+ def get_message_ids_for_chat(self, platform: str, chat_id: str) -> list[str]:
+ """Get all recorded message IDs for a chat (in insertion order)."""
+ chat_key = self._make_chat_key(str(platform), str(chat_id))
+ with self._lock:
+ items = self._message_log.get(chat_key, [])
+ return [
+ str(x.get("message_id"))
+ for x in items
+ if x.get("message_id") is not None
+ ]
+
+ def clear_all(self) -> None:
+ """Clear all stored sessions/trees/mappings and persist an empty store."""
+ with self._lock:
+ self._trees.clear()
+ self._node_to_tree.clear()
+ self._message_log.clear()
+ self._message_log_ids.clear()
+ snapshot = self._flush_save()
+ try:
+ self._write_data(snapshot)
+ except Exception as e:
+ logger.error(f"Failed to save sessions: {e}")
+ with self._lock:
+ self._dirty = True
+
+ # ==================== Tree Methods ====================
+
+ def save_tree(self, root_id: str, tree_data: dict) -> None:
+ """
+ Save a message tree.
+
+ Args:
+ root_id: Root node ID of the tree
+ tree_data: Serialized tree data from tree.to_dict()
+ """
+ with self._lock:
+ self._trees[root_id] = tree_data
+
+ # Update node-to-tree mapping
+ for node_id in tree_data.get("nodes", {}):
+ self._node_to_tree[node_id] = root_id
+
+ self._schedule_save()
+ logger.debug(f"Saved tree {root_id}")
+
+ def get_tree(self, root_id: str) -> dict | None:
+ """Get a tree by its root ID."""
+ with self._lock:
+ return self._trees.get(root_id)
+
+ def register_node(self, node_id: str, root_id: str) -> None:
+ """Register a node ID to a tree root."""
+ with self._lock:
+ self._node_to_tree[node_id] = root_id
+ self._schedule_save()
+
+ def remove_node_mappings(self, node_ids: list[str]) -> None:
+ """Remove node IDs from the node-to-tree mapping."""
+ with self._lock:
+ for nid in node_ids:
+ self._node_to_tree.pop(nid, None)
+ self._schedule_save()
+
+ def remove_tree(self, root_id: str) -> None:
+ """Remove a tree and all its node mappings from the store."""
+ with self._lock:
+ tree_data = self._trees.pop(root_id, None)
+ if tree_data:
+ for node_id in tree_data.get("nodes", {}):
+ self._node_to_tree.pop(node_id, None)
+ self._schedule_save()
+
+ def get_all_trees(self) -> dict[str, dict]:
+ """Get all stored trees (public accessor)."""
+ with self._lock:
+ return dict(self._trees)
+
+ def get_node_mapping(self) -> dict[str, str]:
+ """Get the node-to-tree mapping (public accessor)."""
+ with self._lock:
+ return dict(self._node_to_tree)
+
+ def sync_from_tree_data(
+ self, trees: dict[str, dict], node_to_tree: dict[str, str]
+ ) -> None:
+ """Sync internal tree state from external data and persist."""
+ with self._lock:
+ self._trees = trees
+ self._node_to_tree = node_to_tree
+ self._schedule_save()
diff --git a/Claude_Code/messaging/transcript.py b/Claude_Code/messaging/transcript.py
new file mode 100644
index 0000000000000000000000000000000000000000..073f40bcdb92cc867b88786bd8ca49ac35d32ccc
--- /dev/null
+++ b/Claude_Code/messaging/transcript.py
@@ -0,0 +1,577 @@
+"""Ordered transcript builder for messaging UIs (Telegram, etc.).
+
+This module maintains an ordered list of "segments" that represent what the user
+should see in the chat transcript: thinking, tool calls, tool results, subagent
+headers, and assistant text. It is designed for in-place message editing where
+the transcript grows over time and older content must be truncated.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from abc import ABC, abstractmethod
+from collections import deque
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass, field
+from typing import Any
+
+from loguru import logger
+
+
+def _safe_json_dumps(obj: Any) -> str:
+ try:
+ return json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=True)
+ except Exception:
+ return str(obj)
+
+
+@dataclass
+class Segment(ABC):
+ kind: str
+
+ @abstractmethod
+ def render(self, ctx: RenderCtx) -> str: ...
+
+
+@dataclass
+class ThinkingSegment(Segment):
+ def __init__(self) -> None:
+ super().__init__(kind="thinking")
+ self._parts: list[str] = []
+
+ def append(self, t: str) -> None:
+ if t:
+ self._parts.append(t)
+
+ @property
+ def text(self) -> str:
+ return "".join(self._parts)
+
+ def render(self, ctx: RenderCtx) -> str:
+ raw = self.text or ""
+ if ctx.thinking_tail_max is not None and len(raw) > ctx.thinking_tail_max:
+ raw = "..." + raw[-(ctx.thinking_tail_max - 3) :]
+ inner = ctx.escape_code(raw)
+ return f"💭 {ctx.bold('Thinking')}\n```\n{inner}\n```"
+
+
+@dataclass
+class TextSegment(Segment):
+ def __init__(self) -> None:
+ super().__init__(kind="text")
+ self._parts: list[str] = []
+
+ def append(self, t: str) -> None:
+ if t:
+ self._parts.append(t)
+
+ @property
+ def text(self) -> str:
+ return "".join(self._parts)
+
+ def render(self, ctx: RenderCtx) -> str:
+ raw = self.text or ""
+ if ctx.text_tail_max is not None and len(raw) > ctx.text_tail_max:
+ raw = "..." + raw[-(ctx.text_tail_max - 3) :]
+ return ctx.render_markdown(raw)
+
+
+@dataclass
+class ToolCallSegment(Segment):
+ tool_use_id: str
+ name: str
+ closed: bool = False
+ indent_level: int = 0
+
+ def __init__(self, tool_use_id: str, name: str, *, indent_level: int = 0) -> None:
+ super().__init__(kind="tool_call")
+ self.tool_use_id = str(tool_use_id or "")
+ self.name = str(name or "tool")
+ self.indent_level = max(0, int(indent_level))
+
+ def render(self, ctx: RenderCtx) -> str:
+ name = ctx.code_inline(self.name)
+ # Per UX requirement: do not display tool args/results, only the tool call.
+ prefix = " " * self.indent_level
+ return f"{prefix}🛠 {ctx.bold('Tool call:')} {name}"
+
+
+@dataclass
+class ToolResultSegment(Segment):
+ tool_use_id: str
+ name: str | None
+ content_text: str
+ is_error: bool = False
+
+ def __init__(
+ self,
+ tool_use_id: str,
+ content: Any,
+ *,
+ name: str | None = None,
+ is_error: bool = False,
+ ) -> None:
+ super().__init__(kind="tool_result")
+ self.tool_use_id = str(tool_use_id or "")
+ self.name = str(name) if name is not None else None
+ self.is_error = bool(is_error)
+ if isinstance(content, str):
+ self.content_text = content
+ else:
+ self.content_text = _safe_json_dumps(content)
+
+ def render(self, ctx: RenderCtx) -> str:
+ raw = self.content_text or ""
+ if ctx.tool_output_tail_max is not None and len(raw) > ctx.tool_output_tail_max:
+ raw = "..." + raw[-(ctx.tool_output_tail_max - 3) :]
+ inner = ctx.escape_code(raw)
+ label = "Tool error:" if self.is_error else "Tool result:"
+ maybe_name = f" {ctx.code_inline(self.name)}" if self.name else ""
+ return f"📤 {ctx.bold(label)}{maybe_name}\n```\n{inner}\n```"
+
+
+@dataclass
+class SubagentSegment(Segment):
+ description: str
+ tool_calls: int = 0
+ tools_used: set[str] = field(default_factory=set)
+ current_tool: ToolCallSegment | None = None
+
+ def __init__(self, description: str) -> None:
+ super().__init__(kind="subagent")
+ self.description = str(description or "Subagent")
+ self.tool_calls = 0
+ self.tools_used = set()
+ self.current_tool = None
+
+ def set_current_tool_call(self, tool_use_id: str, name: str) -> ToolCallSegment:
+ tool_use_id = str(tool_use_id or "")
+ name = str(name or "tool")
+ self.tools_used.add(name)
+ self.tool_calls += 1
+ self.current_tool = ToolCallSegment(tool_use_id, name, indent_level=1)
+ return self.current_tool
+
+ def render(self, ctx: RenderCtx) -> str:
+ inner_prefix = " "
+
+ lines: list[str] = [
+ f"🤖 {ctx.bold('Subagent:')} {ctx.code_inline(self.description)}"
+ ]
+
+ if self.current_tool is not None:
+ try:
+ rendered = self.current_tool.render(ctx)
+ except Exception:
+ rendered = ""
+ if rendered:
+ lines.append(rendered)
+
+ tools_used = sorted(self.tools_used)
+ tools_set_raw = "{{{}}}".format(", ".join(tools_used)) if tools_used else "{}"
+
+ # Keep braces inside a code entity so MarkdownV2 doesn't require escaping them.
+ lines.append(
+ f"{inner_prefix}{ctx.bold('Tools used:')} {ctx.code_inline(tools_set_raw)}"
+ )
+ lines.append(
+ f"{inner_prefix}{ctx.bold('Tool calls:')} {ctx.code_inline(str(self.tool_calls))}"
+ )
+ return "\n".join(lines)
+
+
+@dataclass
+class ErrorSegment(Segment):
+ message: str
+
+ def __init__(self, message: str) -> None:
+ super().__init__(kind="error")
+ self.message = str(message or "Unknown error")
+
+ def render(self, ctx: RenderCtx) -> str:
+ return f"⚠️ {ctx.bold('Error:')} {ctx.code_inline(self.message)}"
+
+
+@dataclass
+class RenderCtx:
+ bold: Callable[[str], str]
+ code_inline: Callable[[str], str]
+ escape_code: Callable[[str], str]
+ escape_text: Callable[[str], str]
+ render_markdown: Callable[[str], str]
+
+ thinking_tail_max: int | None = 1000
+ tool_input_tail_max: int | None = 1200
+ tool_output_tail_max: int | None = 1600
+ text_tail_max: int | None = 2000
+
+
+class TranscriptBuffer:
+ """Maintains an ordered, truncatable transcript of events."""
+
+ def __init__(self, *, show_tool_results: bool = True) -> None:
+ self._segments: list[Segment] = []
+ self._open_thinking_by_index: dict[int, ThinkingSegment] = {}
+ self._open_text_by_index: dict[int, TextSegment] = {}
+
+ # content_block index -> tool call segment (for streaming tool args)
+ self._open_tools_by_index: dict[int, ToolCallSegment] = {}
+
+ # tool_use_id -> tool name (for tool_result labeling)
+ self._tool_name_by_id: dict[str, str] = {}
+
+ self._show_tool_results = bool(show_tool_results)
+
+ # subagent context stack. Each entry is the Task tool_use_id we are waiting to close.
+ self._subagent_stack: list[str] = []
+ # Parallel stack of segments for rendering nested subagents.
+ self._subagent_segments: list[SubagentSegment] = []
+ self._debug_subagent_stack = os.getenv("DEBUG_SUBAGENT_STACK") == "1"
+
+ def _in_subagent(self) -> bool:
+ return bool(self._subagent_stack)
+
+ def _subagent_current(self) -> SubagentSegment | None:
+ return self._subagent_segments[-1] if self._subagent_segments else None
+
+ def _task_heading_from_input(self, inp: Any) -> str:
+ # We never display full JSON args; only extract a short heading.
+ if isinstance(inp, dict):
+ desc = str(inp.get("description", "") or "").strip()
+ if desc:
+ return desc
+ subagent_type = str(inp.get("subagent_type", "") or "").strip()
+ if subagent_type:
+ return subagent_type
+ typ = str(inp.get("type", "") or "").strip()
+ if typ:
+ return typ
+ return "Subagent"
+
+ def _subagent_push(self, tool_id: str, seg: SubagentSegment) -> None:
+ # Some providers can omit ids; still track depth for UI suppression.
+ tool_id = (
+ str(tool_id or "").strip() or f"__task_{len(self._subagent_stack) + 1}"
+ )
+ self._subagent_stack.append(tool_id)
+ self._subagent_segments.append(seg)
+ if self._debug_subagent_stack:
+ logger.debug(
+ "SUBAGENT_STACK: push id=%r depth=%d heading=%r",
+ tool_id,
+ len(self._subagent_stack),
+ getattr(seg, "description", None),
+ )
+
+ def _subagent_pop(self, tool_id: str) -> bool:
+ tool_id = str(tool_id or "").strip()
+ if not self._subagent_stack:
+ return False
+
+ def _ids_roughly_match(stack_id: str, result_id: str) -> bool:
+ if not stack_id or not result_id:
+ return False
+ if stack_id == result_id:
+ return True
+ # Some providers emit Task result ids with a suffix/prefix variant.
+ # Treat those as the same logical Task invocation.
+ return stack_id.startswith(result_id) or result_id.startswith(stack_id)
+
+ if tool_id:
+ # O(1) common case: LIFO - top of stack matches.
+ if _ids_roughly_match(self._subagent_stack[-1], tool_id):
+ self._subagent_stack.pop()
+ if self._subagent_segments:
+ self._subagent_segments.pop()
+ if self._debug_subagent_stack:
+ logger.debug(
+ "SUBAGENT_STACK: pop id=%r depth=%d (LIFO)",
+ tool_id,
+ len(self._subagent_stack),
+ )
+ return True
+ # Pop to the matching id (defensive against non-LIFO emissions).
+ idx = -1
+ for i in range(len(self._subagent_stack) - 1, -1, -1):
+ if _ids_roughly_match(self._subagent_stack[i], tool_id):
+ idx = i
+ break
+ if idx < 0:
+ return False
+ while len(self._subagent_stack) > idx:
+ popped = self._subagent_stack.pop()
+ if self._subagent_segments:
+ self._subagent_segments.pop()
+ if self._debug_subagent_stack:
+ logger.debug(
+ "SUBAGENT_STACK: pop id=%r depth=%d (matched=%r)",
+ popped,
+ len(self._subagent_stack),
+ tool_id,
+ )
+ return True
+
+ # No id in result; only close if we have a synthetic top marker.
+ if self._subagent_stack and self._subagent_stack[-1].startswith("__task_"):
+ popped = self._subagent_stack.pop()
+ if self._subagent_segments:
+ self._subagent_segments.pop()
+ if self._debug_subagent_stack:
+ logger.debug(
+ "SUBAGENT_STACK: pop id=%r depth=%d (synthetic)",
+ popped,
+ len(self._subagent_stack),
+ )
+ return True
+ return False
+
+ def _ensure_thinking(self) -> ThinkingSegment:
+ seg = ThinkingSegment()
+ self._segments.append(seg)
+ return seg
+
+ def _ensure_text(self) -> TextSegment:
+ seg = TextSegment()
+ self._segments.append(seg)
+ return seg
+
+ def apply(self, ev: dict[str, Any]) -> None:
+ """Apply a parsed event to the transcript."""
+ et = ev.get("type")
+
+ # Subagent rules: inside a Task/subagent, we only show tool calls/results.
+ if self._in_subagent() and et in (
+ "thinking_start",
+ "thinking_delta",
+ "thinking_chunk",
+ "text_start",
+ "text_delta",
+ "text_chunk",
+ ):
+ return
+
+ if et == "thinking_start":
+ idx = int(ev.get("index", -1))
+ if idx >= 0:
+ # Defensive: if a provider reuses indices without emitting a stop,
+ # close the previous open segment first.
+ self.apply({"type": "block_stop", "index": idx})
+ seg = self._ensure_thinking()
+ if idx >= 0:
+ self._open_thinking_by_index[idx] = seg
+ return
+ if et in ("thinking_delta", "thinking_chunk"):
+ idx = int(ev.get("index", -1))
+ seg = self._open_thinking_by_index.get(idx)
+ if seg is None:
+ seg = self._ensure_thinking()
+ if idx >= 0:
+ self._open_thinking_by_index[idx] = seg
+ seg.append(str(ev.get("text", "")))
+ return
+ if et == "thinking_stop":
+ idx = int(ev.get("index", -1))
+ if idx >= 0:
+ self._open_thinking_by_index.pop(idx, None)
+ return
+
+ if et == "text_start":
+ idx = int(ev.get("index", -1))
+ if idx >= 0:
+ self.apply({"type": "block_stop", "index": idx})
+ seg = self._ensure_text()
+ if idx >= 0:
+ self._open_text_by_index[idx] = seg
+ return
+ if et in ("text_delta", "text_chunk"):
+ idx = int(ev.get("index", -1))
+ seg = self._open_text_by_index.get(idx)
+ if seg is None:
+ seg = self._ensure_text()
+ if idx >= 0:
+ self._open_text_by_index[idx] = seg
+ seg.append(str(ev.get("text", "")))
+ return
+ if et == "text_stop":
+ idx = int(ev.get("index", -1))
+ if idx >= 0:
+ self._open_text_by_index.pop(idx, None)
+ return
+
+ if et == "tool_use_start":
+ idx = int(ev.get("index", -1))
+ if idx >= 0:
+ self.apply({"type": "block_stop", "index": idx})
+ tool_id = str(ev.get("id", "") or "").strip()
+ name = str(ev.get("name", "") or "tool")
+ if tool_id:
+ self._tool_name_by_id[tool_id] = name
+
+ # Task tool indicates subagent.
+ if name == "Task":
+ heading = self._task_heading_from_input(ev.get("input"))
+ seg = SubagentSegment(heading)
+ self._segments.append(seg)
+ self._subagent_push(tool_id, seg)
+ return
+
+ # Normal tool call.
+ if self._in_subagent():
+ parent = self._subagent_current()
+ if parent is not None:
+ seg = parent.set_current_tool_call(tool_id, name)
+ else:
+ seg = ToolCallSegment(tool_id, name)
+ self._segments.append(seg)
+ else:
+ seg = ToolCallSegment(tool_id, name)
+ self._segments.append(seg)
+
+ if idx >= 0:
+ self._open_tools_by_index[idx] = seg
+ return
+
+ if et == "tool_use_delta":
+ # Track open tool by index for tool_use_stop (closing state).
+ return
+
+ if et == "tool_use_stop":
+ idx = int(ev.get("index", -1))
+ seg = self._open_tools_by_index.pop(idx, None)
+ if seg is not None:
+ seg.closed = True
+ return
+
+ if et == "block_stop":
+ idx = int(ev.get("index", -1))
+ if idx in self._open_tools_by_index:
+ self.apply({"type": "tool_use_stop", "index": idx})
+ return
+ if idx in self._open_thinking_by_index:
+ self.apply({"type": "thinking_stop", "index": idx})
+ return
+ if idx in self._open_text_by_index:
+ self.apply({"type": "text_stop", "index": idx})
+ return
+ return
+
+ if et == "tool_use":
+ tool_id = str(ev.get("id", "") or "").strip()
+ name = str(ev.get("name", "") or "tool")
+ if tool_id:
+ self._tool_name_by_id[tool_id] = name
+
+ if name == "Task":
+ heading = self._task_heading_from_input(ev.get("input"))
+ seg = SubagentSegment(heading)
+ self._segments.append(seg)
+ self._subagent_push(tool_id, seg)
+ return
+
+ if self._in_subagent():
+ parent = self._subagent_current()
+ if parent is not None:
+ seg = parent.set_current_tool_call(tool_id, name)
+ else:
+ seg = ToolCallSegment(tool_id, name)
+ self._segments.append(seg)
+ else:
+ seg = ToolCallSegment(tool_id, name)
+ self._segments.append(seg)
+
+ seg.closed = True
+ return
+
+ if et == "tool_result":
+ tool_id = str(ev.get("tool_use_id", "") or "").strip()
+ name = self._tool_name_by_id.get(tool_id)
+
+ # If this was the Task tool result, close subagent context.
+ if self._subagent_stack:
+ popped = self._subagent_pop(tool_id)
+ top = self._subagent_stack[-1] if self._subagent_stack else ""
+ looks_like_task_id = "task" in tool_id.lower()
+ # Some streams omit Task tool_use ids (synthetic stack ids), but include
+ # a real Task id on tool_result (e.g. "functions.Task:0"). Reconcile that.
+ if (
+ not popped
+ and tool_id
+ and top.startswith("__task_")
+ and (name in (None, "Task"))
+ and looks_like_task_id
+ ):
+ self._subagent_pop("")
+
+ if not self._show_tool_results:
+ return
+
+ seg = ToolResultSegment(
+ tool_id,
+ ev.get("content"),
+ name=name,
+ is_error=bool(ev.get("is_error", False)),
+ )
+ self._segments.append(seg)
+ return
+
+ if et == "error":
+ self._segments.append(ErrorSegment(str(ev.get("message", ""))))
+ return
+
+ def render(self, ctx: RenderCtx, *, limit_chars: int, status: str | None) -> str:
+ """Render transcript with truncation (drop oldest segments)."""
+ # Filter out empty rendered segments.
+ rendered: list[str] = []
+ for seg in self._segments:
+ try:
+ out = seg.render(ctx)
+ except Exception:
+ continue
+ if out:
+ rendered.append(out)
+
+ status_text = f"\n\n{status}" if status else ""
+ prefix_marker = ctx.escape_text("... (truncated)\n")
+
+ def _join(parts: Iterable[str], add_marker: bool) -> str:
+ body = "\n".join(parts)
+ if add_marker and body:
+ body = prefix_marker + body
+ return body + status_text if (body or status_text) else status_text
+
+ # Fast path.
+ candidate = _join(rendered, add_marker=False)
+ if len(candidate) <= limit_chars:
+ return candidate
+
+ # Drop oldest segments until under limit (keep the tail).
+ # Use deque for O(1) popleft; list.pop(0) would be O(n) per iteration.
+ parts: deque[str] = deque(rendered)
+ dropped = False
+ last_part: str | None = None
+ while parts:
+ candidate = _join(parts, add_marker=True)
+ if len(candidate) <= limit_chars:
+ return candidate
+ last_part = parts.popleft()
+ dropped = True
+
+ # Nothing fits - preserve tail of last segment instead of only marker+status.
+ if dropped and last_part:
+ budget = limit_chars - len(prefix_marker) - len(status_text)
+ if budget > 20:
+ if len(last_part) > budget:
+ tail = "..." + last_part[-(budget - 3) :]
+ else:
+ tail = last_part
+ candidate = prefix_marker + tail + status_text
+ if len(candidate) <= limit_chars:
+ return candidate
+
+ # Fallback: marker + status only.
+ if dropped:
+ minimal = prefix_marker + status_text.lstrip("\n")
+ if len(minimal) <= limit_chars:
+ return minimal
+ return status or ""
diff --git a/Claude_Code/messaging/transcription.py b/Claude_Code/messaging/transcription.py
new file mode 100644
index 0000000000000000000000000000000000000000..37524b5b65dc66cf9cf67a3e89f62aa4fddce4f4
--- /dev/null
+++ b/Claude_Code/messaging/transcription.py
@@ -0,0 +1,228 @@
+"""Voice note transcription for messaging platforms.
+
+Supports:
+- Local Whisper (cpu/cuda): Hugging Face transformers pipeline
+- NVIDIA NIM: NVIDIA NIM Whisper/Parakeet
+"""
+
+import os
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+
+from config.settings import get_settings
+
+# Max file size in bytes (25 MB)
+MAX_AUDIO_SIZE_BYTES = 25 * 1024 * 1024
+
+# NVIDIA NIM Whisper model mapping: (function_id, language_code)
+_NIM_MODEL_MAP: dict[str, tuple[str, str]] = {
+ "nvidia/parakeet-ctc-0.6b-zh-tw": ("8473f56d-51ef-473c-bb26-efd4f5def2bf", "zh-TW"),
+ "nvidia/parakeet-ctc-0.6b-zh-cn": ("9add5ef7-322e-47e0-ad7a-5653fb8d259b", "zh-CN"),
+ "nvidia/parakeet-ctc-0.6b-es": ("None", "es-US"),
+ "nvidia/parakeet-ctc-0.6b-vi": ("f3dff2bb-99f9-403d-a5f1-f574a757deb0", "vi-VN"),
+ "nvidia/parakeet-ctc-1.1b-asr": ("1598d209-5e27-4d3c-8079-4751568b1081", "en-US"),
+ "nvidia/parakeet-ctc-0.6b-asr": ("d8dd4e9b-fbf5-4fb0-9dba-8cf436c8d965", "en-US"),
+ "nvidia/parakeet-1.1b-rnnt-multilingual-asr": (
+ "71203149-d3b7-4460-8231-1be2543a1fca",
+ "",
+ ),
+ "openai/whisper-large-v3": ("b702f636-f60c-4a3d-a6f4-f3568c13bd7d", "multi"),
+}
+
+# Short model names -> full Hugging Face model IDs (for local Whisper)
+_MODEL_MAP: dict[str, str] = {
+ "tiny": "openai/whisper-tiny",
+ "base": "openai/whisper-base",
+ "small": "openai/whisper-small",
+ "medium": "openai/whisper-medium",
+ "large-v2": "openai/whisper-large-v2",
+ "large-v3": "openai/whisper-large-v3",
+ "large-v3-turbo": "openai/whisper-large-v3-turbo",
+}
+
+# Lazy-loaded pipelines: (model_id, device) -> pipeline
+_pipeline_cache: dict[tuple[str, str], Any] = {}
+
+
+def _resolve_model_id(whisper_model: str) -> str:
+ """Resolve short name to full Hugging Face model ID."""
+ return _MODEL_MAP.get(whisper_model, whisper_model)
+
+
+def _get_pipeline(model_id: str, device: str) -> Any:
+ """Lazy-load transformers Whisper pipeline. Raises ImportError if not installed."""
+ global _pipeline_cache
+ if device not in ("cpu", "cuda"):
+ raise ValueError(f"whisper_device must be 'cpu' or 'cuda', got {device!r}")
+ cache_key = (model_id, device)
+ if cache_key not in _pipeline_cache:
+ try:
+ import torch
+ from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
+
+ token = get_settings().hf_token
+ if token:
+ os.environ["HF_TOKEN"] = token
+
+ use_cuda = device == "cuda" and torch.cuda.is_available()
+ pipe_device = "cuda:0" if use_cuda else "cpu"
+ model_dtype = torch.float16 if use_cuda else torch.float32
+
+ model = AutoModelForSpeechSeq2Seq.from_pretrained(
+ model_id,
+ dtype=model_dtype,
+ low_cpu_mem_usage=True,
+ attn_implementation="sdpa",
+ )
+ model = model.to(pipe_device)
+ processor = AutoProcessor.from_pretrained(model_id)
+
+ pipe = pipeline(
+ "automatic-speech-recognition",
+ model=model,
+ tokenizer=processor.tokenizer,
+ feature_extractor=processor.feature_extractor,
+ device=pipe_device,
+ )
+ _pipeline_cache[cache_key] = pipe
+ logger.debug(
+ f"Loaded Whisper pipeline: model={model_id} device={pipe_device}"
+ )
+ except ImportError as e:
+ raise ImportError(
+ "Local Whisper requires the voice_local extra. Install with: uv sync --extra voice_local"
+ ) from e
+ return _pipeline_cache[cache_key]
+
+
+def transcribe_audio(
+ file_path: Path,
+ mime_type: str,
+ *,
+ whisper_model: str = "base",
+ whisper_device: str = "cpu",
+) -> str:
+ """
+ Transcribe audio file to text.
+
+ Supports:
+ - whisper_device="cpu"/"cuda": local Whisper (requires voice_local extra)
+ - whisper_device="nvidia_nim": NVIDIA NIM Whisper API (requires voice extra)
+
+ Args:
+ file_path: Path to audio file (OGG, MP3, MP4, WAV, M4A supported)
+ mime_type: MIME type of the audio (e.g. "audio/ogg")
+ whisper_model: Model ID or short name (local) or NVIDIA NIM model
+ whisper_device: "cpu" | "cuda" | "nvidia_nim" (defaults to WHISPER_DEVICE env var)
+
+ Returns:
+ Transcribed text
+
+ Raises:
+ FileNotFoundError: If file does not exist
+ ValueError: If file too large
+ ImportError: If voice_local extra not installed (for local Whisper)
+ """
+
+ if not file_path.exists():
+ raise FileNotFoundError(f"Audio file not found: {file_path}")
+
+ size = file_path.stat().st_size
+ if size > MAX_AUDIO_SIZE_BYTES:
+ raise ValueError(
+ f"Audio file too large ({size} bytes). Max {MAX_AUDIO_SIZE_BYTES} bytes."
+ )
+
+ if whisper_device == "nvidia_nim":
+ return _transcribe_nim(file_path, whisper_model)
+ else:
+ return _transcribe_local(file_path, whisper_model, whisper_device)
+
+
+# Whisper expects 16 kHz sample rate
+_WHISPER_SAMPLE_RATE = 16000
+
+
+def _load_audio(file_path: Path) -> dict[str, Any]:
+ """Load audio file to waveform dict. No ffmpeg required."""
+ import librosa
+
+ waveform, sr = librosa.load(str(file_path), sr=_WHISPER_SAMPLE_RATE, mono=True)
+ return {"array": waveform, "sampling_rate": sr}
+
+
+def _transcribe_local(file_path: Path, whisper_model: str, whisper_device: str) -> str:
+ """Transcribe using transformers Whisper pipeline."""
+ model_id = _resolve_model_id(whisper_model)
+ pipe = _get_pipeline(model_id, whisper_device)
+ audio = _load_audio(file_path)
+ result = pipe(audio, generate_kwargs={"language": "en", "task": "transcribe"})
+ text = result.get("text", "") or ""
+ if isinstance(text, list):
+ text = " ".join(text) if text else ""
+ result_text = text.strip()
+ logger.debug(f"Local transcription: {len(result_text)} chars")
+ return result_text or "(no speech detected)"
+
+
+def _transcribe_nim(file_path: Path, model: str) -> str:
+ """Transcribe using NVIDIA NIM Whisper API via Riva gRPC client."""
+ try:
+ import riva.client
+ except ImportError as e:
+ raise ImportError(
+ "NVIDIA NIM transcription requires the voice extra. "
+ "Install with: uv sync --extra voice"
+ ) from e
+
+ settings = get_settings()
+ api_key = settings.nvidia_nim_api_key
+
+ # Look up function ID and language code from model mapping
+ model_config = _NIM_MODEL_MAP.get(model)
+ if not model_config:
+ raise ValueError(
+ f"No NVIDIA NIM config found for model: {model}. "
+ f"Supported models: {', '.join(_NIM_MODEL_MAP.keys())}"
+ )
+ function_id, language_code = model_config
+
+ # Riva server configuration
+ server = "grpc.nvcf.nvidia.com:443"
+
+ # Auth with SSL and metadata
+ auth = riva.client.Auth(
+ use_ssl=True,
+ uri=server,
+ metadata_args=[
+ ["function-id", function_id],
+ ["authorization", f"Bearer {api_key}"],
+ ],
+ )
+
+ asr_service = riva.client.ASRService(auth)
+
+ # Configure recognition - language_code from model config
+ config = riva.client.RecognitionConfig(
+ language_code=language_code,
+ max_alternatives=1,
+ verbatim_transcripts=True,
+ )
+
+ # Read audio file
+ with open(file_path, "rb") as f:
+ data = f.read()
+
+ # Perform offline recognition
+ response = asr_service.offline_recognize(data, config)
+
+ # Extract text from response - use getattr for safe attribute access
+ transcript = ""
+ results = getattr(response, "results", None)
+ if results and results[0].alternatives:
+ transcript = results[0].alternatives[0].transcript
+
+ logger.debug(f"NIM transcription: {len(transcript)} chars")
+ return transcript or "(no speech detected)"
diff --git a/Claude_Code/messaging/trees/__init__.py b/Claude_Code/messaging/trees/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3b556eb232dd54403db36572f474d81e559c698
--- /dev/null
+++ b/Claude_Code/messaging/trees/__init__.py
@@ -0,0 +1,11 @@
+"""Message tree data structures and queue management."""
+
+from .data import MessageNode, MessageState, MessageTree
+from .queue_manager import TreeQueueManager
+
+__all__ = [
+ "MessageNode",
+ "MessageState",
+ "MessageTree",
+ "TreeQueueManager",
+]
diff --git a/Claude_Code/messaging/trees/data.py b/Claude_Code/messaging/trees/data.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5db01dbb37a789efc968a17c787301f3f5387c3
--- /dev/null
+++ b/Claude_Code/messaging/trees/data.py
@@ -0,0 +1,482 @@
+"""Tree data structures for message queue.
+
+Contains MessageState, MessageNode, and MessageTree classes.
+"""
+
+import asyncio
+from collections import deque
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from enum import Enum
+from typing import Any
+
+from loguru import logger
+
+from ..models import IncomingMessage
+
+
+class _SnapshotQueue:
+ """Queue with snapshot/remove helpers, backed by a deque and a set index."""
+
+ def __init__(self) -> None:
+ self._deque: deque[str] = deque()
+ self._set: set[str] = set()
+
+ async def put(self, item: str) -> None:
+ self._deque.append(item)
+ self._set.add(item)
+
+ def put_nowait(self, item: str) -> None:
+ self._deque.append(item)
+ self._set.add(item)
+
+ def get_nowait(self) -> str:
+ if not self._deque:
+ raise asyncio.QueueEmpty()
+ item = self._deque.popleft()
+ self._set.discard(item)
+ return item
+
+ def qsize(self) -> int:
+ return len(self._deque)
+
+ def get_snapshot(self) -> list[str]:
+ """Return current queue contents in FIFO order (read-only copy)."""
+ return list(self._deque)
+
+ def remove_if_present(self, item: str) -> bool:
+ """Remove item from queue if present (O(1) membership check). Returns True if removed."""
+ if item not in self._set:
+ return False
+ self._set.discard(item)
+ self._deque = deque(x for x in self._deque if x != item)
+ return True
+
+
+class MessageState(Enum):
+ """State of a message node in the tree."""
+
+ PENDING = "pending" # Queued, waiting to be processed
+ IN_PROGRESS = "in_progress" # Currently being processed by Claude
+ COMPLETED = "completed" # Processing finished successfully
+ ERROR = "error" # Processing failed
+
+
+@dataclass
+class MessageNode:
+ """
+ A node in the message tree.
+
+ Each node represents a single message and tracks:
+ - Its relationship to parent/children
+ - Its processing state
+ - Claude session information
+ """
+
+ node_id: str # Unique ID (typically message_id)
+ incoming: IncomingMessage # The original message
+ status_message_id: str # Bot's status message ID
+ state: MessageState = MessageState.PENDING
+ parent_id: str | None = None # Parent node ID (None for root)
+ session_id: str | None = None # Claude session ID (forked from parent)
+ children_ids: list[str] = field(default_factory=list)
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
+ completed_at: datetime | None = None
+ error_message: str | None = None
+ context: Any = None # Additional context if needed
+
+ def set_context(self, context: Any) -> None:
+ self.context = context
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for JSON serialization."""
+ return {
+ "node_id": self.node_id,
+ "incoming": {
+ "text": self.incoming.text,
+ "chat_id": self.incoming.chat_id,
+ "user_id": self.incoming.user_id,
+ "message_id": self.incoming.message_id,
+ "platform": self.incoming.platform,
+ "reply_to_message_id": self.incoming.reply_to_message_id,
+ "message_thread_id": self.incoming.message_thread_id,
+ "username": self.incoming.username,
+ },
+ "status_message_id": self.status_message_id,
+ "state": self.state.value,
+ "parent_id": self.parent_id,
+ "session_id": self.session_id,
+ "children_ids": self.children_ids,
+ "created_at": self.created_at.isoformat(),
+ "completed_at": self.completed_at.isoformat()
+ if self.completed_at
+ else None,
+ "error_message": self.error_message,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> MessageNode:
+ """Create from dictionary (JSON deserialization)."""
+ incoming_data = data["incoming"]
+ incoming = IncomingMessage(
+ text=incoming_data["text"],
+ chat_id=incoming_data["chat_id"],
+ user_id=incoming_data["user_id"],
+ message_id=incoming_data["message_id"],
+ platform=incoming_data["platform"],
+ reply_to_message_id=incoming_data.get("reply_to_message_id"),
+ message_thread_id=incoming_data.get("message_thread_id"),
+ username=incoming_data.get("username"),
+ )
+ return cls(
+ node_id=data["node_id"],
+ incoming=incoming,
+ status_message_id=data["status_message_id"],
+ state=MessageState(data["state"]),
+ parent_id=data.get("parent_id"),
+ session_id=data.get("session_id"),
+ children_ids=data.get("children_ids", []),
+ created_at=datetime.fromisoformat(data["created_at"]),
+ completed_at=datetime.fromisoformat(data["completed_at"])
+ if data.get("completed_at")
+ else None,
+ error_message=data.get("error_message"),
+ )
+
+
+class MessageTree:
+ """
+ A tree of message nodes with queue functionality.
+
+ Provides:
+ - O(1) node lookup via hashmap
+ - Per-tree message queue
+ - Thread-safe operations via asyncio.Lock
+ """
+
+ def __init__(self, root_node: MessageNode):
+ """
+ Initialize tree with a root node.
+
+ Args:
+ root_node: The root message node
+ """
+ self.root_id = root_node.node_id
+ self._nodes: dict[str, MessageNode] = {root_node.node_id: root_node}
+ self._status_to_node: dict[str, str] = {
+ root_node.status_message_id: root_node.node_id
+ }
+ self._queue: _SnapshotQueue = _SnapshotQueue()
+ self._lock = asyncio.Lock()
+ self._is_processing = False
+ self._current_node_id: str | None = None
+ self._current_task: asyncio.Task | None = None
+
+ logger.debug(f"Created MessageTree with root {self.root_id}")
+
+ def set_current_task(self, task: asyncio.Task | None) -> None:
+ """Set the current processing task. Caller must hold lock."""
+ self._current_task = task
+
+ @property
+ def is_processing(self) -> bool:
+ """Check if tree is currently processing a message."""
+ return self._is_processing
+
+ async def add_node(
+ self,
+ node_id: str,
+ incoming: IncomingMessage,
+ status_message_id: str,
+ parent_id: str,
+ ) -> MessageNode:
+ """
+ Add a child node to the tree.
+
+ Args:
+ node_id: Unique ID for the new node
+ incoming: The incoming message
+ status_message_id: Bot's status message ID
+ parent_id: Parent node ID
+
+ Returns:
+ The created MessageNode
+ """
+ async with self._lock:
+ if parent_id not in self._nodes:
+ raise ValueError(f"Parent node {parent_id} not found in tree")
+
+ node = MessageNode(
+ node_id=node_id,
+ incoming=incoming,
+ status_message_id=status_message_id,
+ parent_id=parent_id,
+ state=MessageState.PENDING,
+ )
+
+ self._nodes[node_id] = node
+ self._status_to_node[status_message_id] = node_id
+ self._nodes[parent_id].children_ids.append(node_id)
+
+ logger.debug(f"Added node {node_id} as child of {parent_id}")
+ return node
+
+ def get_node(self, node_id: str) -> MessageNode | None:
+ """Get a node by ID (O(1) lookup)."""
+ return self._nodes.get(node_id)
+
+ def get_root(self) -> MessageNode:
+ """Get the root node."""
+ return self._nodes[self.root_id]
+
+ def get_children(self, node_id: str) -> list[MessageNode]:
+ """Get all child nodes of a given node."""
+ node = self._nodes.get(node_id)
+ if not node:
+ return []
+ return [self._nodes[cid] for cid in node.children_ids if cid in self._nodes]
+
+ def get_parent(self, node_id: str) -> MessageNode | None:
+ """Get the parent node."""
+ node = self._nodes.get(node_id)
+ if not node or not node.parent_id:
+ return None
+ return self._nodes.get(node.parent_id)
+
+ def get_parent_session_id(self, node_id: str) -> str | None:
+ """
+ Get the parent's session ID for forking.
+
+ Returns None for root nodes.
+ """
+ parent = self.get_parent(node_id)
+ return parent.session_id if parent else None
+
+ async def update_state(
+ self,
+ node_id: str,
+ state: MessageState,
+ session_id: str | None = None,
+ error_message: str | None = None,
+ ) -> None:
+ """Update a node's state."""
+ async with self._lock:
+ node = self._nodes.get(node_id)
+ if not node:
+ logger.warning(f"Node {node_id} not found for state update")
+ return
+
+ node.state = state
+ if session_id:
+ node.session_id = session_id
+ if error_message:
+ node.error_message = error_message
+ if state in (MessageState.COMPLETED, MessageState.ERROR):
+ node.completed_at = datetime.now(UTC)
+
+ logger.debug(f"Node {node_id} state -> {state.value}")
+
+ async def enqueue(self, node_id: str) -> int:
+ """
+ Add a node to the processing queue.
+
+ Returns:
+ Queue position (1-indexed)
+ """
+ async with self._lock:
+ await self._queue.put(node_id)
+ position = self._queue.qsize()
+ logger.debug(f"Enqueued node {node_id}, position {position}")
+ return position
+
+ async def dequeue(self) -> str | None:
+ """
+ Get the next node ID from the queue.
+
+ Returns None if queue is empty.
+ """
+ try:
+ return self._queue.get_nowait()
+ except asyncio.QueueEmpty:
+ return None
+
+ async def get_queue_snapshot(self) -> list[str]:
+ """
+ Get a snapshot of the current queue order.
+
+ Returns:
+ List of node IDs in FIFO order.
+ """
+ async with self._lock:
+ return self._queue.get_snapshot()
+
+ def get_queue_size(self) -> int:
+ """Get number of messages waiting in queue."""
+ return self._queue.qsize()
+
+ def remove_from_queue(self, node_id: str) -> bool:
+ """
+ Remove node_id from the internal queue if present.
+
+ Caller must hold the tree lock (e.g. via with_lock).
+ Returns True if node was removed, False if not in queue.
+ """
+ return self._queue.remove_if_present(node_id)
+
+ @asynccontextmanager
+ async def with_lock(self):
+ """Async context manager for tree lock. Use when multiple operations need atomicity."""
+ async with self._lock:
+ yield
+
+ def set_processing_state(self, node_id: str | None, is_processing: bool) -> None:
+ """Set processing state. Caller must hold lock for consistency with queue operations."""
+ self._is_processing = is_processing
+ self._current_node_id = node_id if is_processing else None
+
+ def clear_current_node(self) -> None:
+ """Clear the currently processing node ID. Caller must hold lock."""
+ self._current_node_id = None
+
+ def is_current_node(self, node_id: str) -> bool:
+ """Check if node_id is the currently processing node."""
+ return self._current_node_id == node_id
+
+ def put_queue_unlocked(self, node_id: str) -> None:
+ """Add node to queue. Caller must hold lock (e.g. via with_lock)."""
+ self._queue.put_nowait(node_id)
+
+ def cancel_current_task(self) -> bool:
+ """Cancel the currently running task. Returns True if a task was cancelled."""
+ if self._current_task and not self._current_task.done():
+ self._current_task.cancel()
+ return True
+ return False
+
+ def set_node_error_sync(self, node: MessageNode, error_message: str) -> None:
+ """Synchronously mark a node as ERROR. Caller must ensure no concurrent access."""
+ node.state = MessageState.ERROR
+ node.error_message = error_message
+ node.completed_at = datetime.now(UTC)
+
+ def drain_queue_and_mark_cancelled(
+ self, error_message: str = "Cancelled by user"
+ ) -> list[MessageNode]:
+ """
+ Drain the queue, mark each node as ERROR, and return affected nodes.
+ Does not acquire lock; caller must ensure no concurrent queue access.
+ """
+ nodes: list[MessageNode] = []
+ while True:
+ try:
+ node_id = self._queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ node = self._nodes.get(node_id)
+ if node:
+ self.set_node_error_sync(node, error_message)
+ nodes.append(node)
+ return nodes
+
+ def reset_processing_state(self) -> None:
+ """Reset processing flags after cancel/cleanup."""
+ self._is_processing = False
+ self._current_node_id = None
+
+ @property
+ def current_node_id(self) -> str | None:
+ """Get the ID of the node currently being processed."""
+ return self._current_node_id
+
+ def to_dict(self) -> dict:
+ """Serialize tree to dictionary."""
+ return {
+ "root_id": self.root_id,
+ "nodes": {nid: node.to_dict() for nid, node in self._nodes.items()},
+ }
+
+ def _add_node_from_dict(self, node: MessageNode) -> None:
+ """Register a deserialized node into the tree's internal indices."""
+ self._nodes[node.node_id] = node
+ self._status_to_node[node.status_message_id] = node.node_id
+
+ @classmethod
+ def from_dict(cls, data: dict) -> MessageTree:
+ """Deserialize tree from dictionary."""
+ root_id = data["root_id"]
+ nodes_data = data["nodes"]
+
+ # Create root node first
+ root_node = MessageNode.from_dict(nodes_data[root_id])
+ tree = cls(root_node)
+
+ # Add remaining nodes and build status->node index
+ for node_id, node_data in nodes_data.items():
+ if node_id != root_id:
+ node = MessageNode.from_dict(node_data)
+ tree._add_node_from_dict(node)
+
+ return tree
+
+ def all_nodes(self) -> list[MessageNode]:
+ """Get all nodes in the tree."""
+ return list(self._nodes.values())
+
+ def has_node(self, node_id: str) -> bool:
+ """Check if a node exists in this tree."""
+ return node_id in self._nodes
+
+ def find_node_by_status_message(self, status_msg_id: str) -> MessageNode | None:
+ """Find the node that has this status message ID (O(1) lookup)."""
+ node_id = self._status_to_node.get(status_msg_id)
+ return self._nodes.get(node_id) if node_id else None
+
+ def get_descendants(self, node_id: str) -> list[str]:
+ """
+ Get node_id and all descendant IDs (subtree).
+
+ Returns:
+ List of node IDs including the given node.
+ """
+ if node_id not in self._nodes:
+ return []
+ result: list[str] = []
+ stack = [node_id]
+ while stack:
+ nid = stack.pop()
+ result.append(nid)
+ node = self._nodes.get(nid)
+ if node:
+ stack.extend(node.children_ids)
+ return result
+
+ def remove_branch(self, branch_root_id: str) -> list[MessageNode]:
+ """
+ Remove a subtree (branch_root and all descendants) from the tree.
+
+ Updates parent's children_ids. Caller must hold lock for consistency.
+ Does not acquire lock internally.
+
+ Returns:
+ List of removed nodes.
+ """
+ if branch_root_id not in self._nodes:
+ return []
+
+ parent = self.get_parent(branch_root_id)
+ removed = []
+ for nid in self.get_descendants(branch_root_id):
+ node = self._nodes.get(nid)
+ if node:
+ removed.append(node)
+ del self._nodes[nid]
+ del self._status_to_node[node.status_message_id]
+
+ if parent and branch_root_id in parent.children_ids:
+ parent.children_ids = [
+ c for c in parent.children_ids if c != branch_root_id
+ ]
+
+ logger.debug(f"Removed branch {branch_root_id} ({len(removed)} nodes)")
+ return removed
diff --git a/Claude_Code/messaging/trees/processor.py b/Claude_Code/messaging/trees/processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9255b43ac42fe7c64a150ff89af72a58ca18dce
--- /dev/null
+++ b/Claude_Code/messaging/trees/processor.py
@@ -0,0 +1,165 @@
+"""Async queue processor for message trees.
+
+Handles the async processing lifecycle of tree nodes.
+"""
+
+import asyncio
+from collections.abc import Awaitable, Callable
+
+from loguru import logger
+
+from providers.common import get_user_facing_error_message
+
+from .data import MessageNode, MessageState, MessageTree
+
+
+class TreeQueueProcessor:
+ """
+ Handles async queue processing for a single tree.
+
+ Separates the async processing logic from the data management.
+ """
+
+ def __init__(
+ self,
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None = None,
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]]
+ | None = None,
+ ):
+ self._queue_update_callback = queue_update_callback
+ self._node_started_callback = node_started_callback
+
+ def set_queue_update_callback(
+ self,
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None,
+ ) -> None:
+ """Update the callback used to refresh queue positions."""
+ self._queue_update_callback = queue_update_callback
+
+ def set_node_started_callback(
+ self,
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]] | None,
+ ) -> None:
+ """Update the callback used when a queued node starts processing."""
+ self._node_started_callback = node_started_callback
+
+ async def _notify_queue_updated(self, tree: MessageTree) -> None:
+ """Invoke queue update callback if set."""
+ if not self._queue_update_callback:
+ return
+ try:
+ await self._queue_update_callback(tree)
+ except Exception as e:
+ logger.warning(f"Queue update callback failed: {e}")
+
+ async def _notify_node_started(self, tree: MessageTree, node_id: str) -> None:
+ """Invoke node started callback if set."""
+ if not self._node_started_callback:
+ return
+ try:
+ await self._node_started_callback(tree, node_id)
+ except Exception as e:
+ logger.warning(f"Node started callback failed: {e}")
+
+ async def process_node(
+ self,
+ tree: MessageTree,
+ node: MessageNode,
+ processor: Callable[[str, MessageNode], Awaitable[None]],
+ ) -> None:
+ """Process a single node and then check the queue."""
+ # Skip if already in terminal state (e.g. from error propagation)
+ if node.state == MessageState.ERROR:
+ logger.info(
+ f"Skipping node {node.node_id} as it is already in state {node.state}"
+ )
+ # Still need to check for next messages
+ await self._process_next(tree, processor)
+ return
+
+ try:
+ await processor(node.node_id, node)
+ except asyncio.CancelledError:
+ logger.info(f"Task for node {node.node_id} was cancelled")
+ raise
+ except Exception as e:
+ logger.error(f"Error processing node {node.node_id}: {e}")
+ await tree.update_state(
+ node.node_id,
+ MessageState.ERROR,
+ error_message=get_user_facing_error_message(e),
+ )
+ finally:
+ async with tree.with_lock():
+ tree.clear_current_node()
+ # Check if there are more messages in the queue
+ await self._process_next(tree, processor)
+
+ async def _process_next(
+ self,
+ tree: MessageTree,
+ processor: Callable[[str, MessageNode], Awaitable[None]],
+ ) -> None:
+ """Process the next message in queue, if any."""
+ next_node_id = None
+ node = None
+ async with tree.with_lock():
+ next_node_id = await tree.dequeue()
+
+ if not next_node_id:
+ tree.set_processing_state(None, False)
+ logger.debug(f"Tree {tree.root_id} queue empty, marking as free")
+ return
+
+ tree.set_processing_state(next_node_id, True)
+ logger.info(f"Processing next queued node {next_node_id}")
+
+ # Process next node (outside lock)
+ node = tree.get_node(next_node_id)
+ if node:
+ tree.set_current_task(
+ asyncio.create_task(self.process_node(tree, node, processor))
+ )
+
+ # Notify that this node has started processing and refresh queue positions.
+ if next_node_id:
+ await self._notify_node_started(tree, next_node_id)
+ await self._notify_queue_updated(tree)
+
+ async def enqueue_and_start(
+ self,
+ tree: MessageTree,
+ node_id: str,
+ processor: Callable[[str, MessageNode], Awaitable[None]],
+ ) -> bool:
+ """
+ Enqueue a node or start processing immediately.
+
+ Args:
+ tree: The message tree
+ node_id: Node to process
+ processor: Async function to process the node
+
+ Returns:
+ True if queued, False if processing immediately
+ """
+ async with tree.with_lock():
+ if tree.is_processing:
+ tree.put_queue_unlocked(node_id)
+ queue_size = tree.get_queue_size()
+ logger.info(f"Queued node {node_id}, position {queue_size}")
+ return True
+ else:
+ tree.set_processing_state(node_id, True)
+
+ # Process outside the lock
+ node = tree.get_node(node_id)
+ if node:
+ tree.set_current_task(
+ asyncio.create_task(self.process_node(tree, node, processor))
+ )
+ return False
+
+ def cancel_current(self, tree: MessageTree) -> bool:
+ """Cancel the currently running task in a tree."""
+ return tree.cancel_current_task()
diff --git a/Claude_Code/messaging/trees/queue_manager.py b/Claude_Code/messaging/trees/queue_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..14b0f100ca946e998155cdac49526fe43722e634
--- /dev/null
+++ b/Claude_Code/messaging/trees/queue_manager.py
@@ -0,0 +1,445 @@
+"""Tree-Based Message Queue Manager - Refactored.
+
+Coordinates data access, async processing, and error handling.
+Uses TreeRepository for data, TreeQueueProcessor for async logic.
+"""
+
+import asyncio
+from collections.abc import Awaitable, Callable
+
+from loguru import logger
+
+from ..models import IncomingMessage
+from .data import MessageNode, MessageState, MessageTree
+from .processor import TreeQueueProcessor
+from .repository import TreeRepository
+
+# Backward compatibility: re-export moved classes
+__all__ = [
+ "MessageNode",
+ "MessageState",
+ "MessageTree",
+ "TreeQueueManager",
+]
+
+
+class TreeQueueManager:
+ """
+ Manages multiple message trees. Facade that coordinates components.
+
+ Each new conversation creates a new tree.
+ Replies to existing messages add nodes to existing trees.
+
+ Components:
+ - TreeRepository: Data access layer
+ - TreeQueueProcessor: Async queue processing
+ """
+
+ def __init__(
+ self,
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None = None,
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]]
+ | None = None,
+ _repository: TreeRepository | None = None,
+ ):
+ self._repository = _repository or TreeRepository()
+ self._processor = TreeQueueProcessor(
+ queue_update_callback=queue_update_callback,
+ node_started_callback=node_started_callback,
+ )
+ self._lock = asyncio.Lock()
+
+ logger.info("TreeQueueManager initialized")
+
+ async def create_tree(
+ self,
+ node_id: str,
+ incoming: IncomingMessage,
+ status_message_id: str,
+ ) -> MessageTree:
+ """
+ Create a new tree with a root node.
+
+ Args:
+ node_id: ID for the root node
+ incoming: The incoming message
+ status_message_id: Bot's status message ID
+
+ Returns:
+ The created MessageTree
+ """
+ async with self._lock:
+ root_node = MessageNode(
+ node_id=node_id,
+ incoming=incoming,
+ status_message_id=status_message_id,
+ state=MessageState.PENDING,
+ )
+
+ tree = MessageTree(root_node)
+ self._repository.add_tree(node_id, tree)
+
+ logger.info(f"Created new tree with root {node_id}")
+ return tree
+
+ async def add_to_tree(
+ self,
+ parent_node_id: str,
+ node_id: str,
+ incoming: IncomingMessage,
+ status_message_id: str,
+ ) -> tuple[MessageTree, MessageNode]:
+ """
+ Add a reply as a child node to an existing tree.
+
+ Args:
+ parent_node_id: ID of the parent message
+ node_id: ID for the new node
+ incoming: The incoming reply message
+ status_message_id: Bot's status message ID
+
+ Returns:
+ Tuple of (tree, new_node)
+ """
+ async with self._lock:
+ if not self._repository.has_node(parent_node_id):
+ raise ValueError(f"Parent node {parent_node_id} not found in any tree")
+
+ tree = self._repository.get_tree_for_node(parent_node_id)
+ if not tree:
+ raise ValueError(f"Parent node {parent_node_id} not found in any tree")
+
+ # Add node (tree has its own lock) - outside manager lock to avoid deadlock
+ node = await tree.add_node(
+ node_id=node_id,
+ incoming=incoming,
+ status_message_id=status_message_id,
+ parent_id=parent_node_id,
+ )
+
+ async with self._lock:
+ self._repository.register_node(node_id, tree.root_id)
+
+ logger.info(f"Added node {node_id} to tree {tree.root_id}")
+ return tree, node
+
+ def get_tree(self, root_id: str) -> MessageTree | None:
+ """Get a tree by its root ID."""
+ return self._repository.get_tree(root_id)
+
+ def get_tree_for_node(self, node_id: str) -> MessageTree | None:
+ """Get the tree containing a given node."""
+ return self._repository.get_tree_for_node(node_id)
+
+ def get_node(self, node_id: str) -> MessageNode | None:
+ """Get a node from any tree."""
+ return self._repository.get_node(node_id)
+
+ def resolve_parent_node_id(self, msg_id: str) -> str | None:
+ """Resolve a message ID to the actual parent node ID."""
+ return self._repository.resolve_parent_node_id(msg_id)
+
+ def is_tree_busy(self, root_id: str) -> bool:
+ """Check if a tree is currently processing."""
+ return self._repository.is_tree_busy(root_id)
+
+ def is_node_tree_busy(self, node_id: str) -> bool:
+ """Check if the tree containing a node is busy."""
+ return self._repository.is_node_tree_busy(node_id)
+
+ async def enqueue(
+ self,
+ node_id: str,
+ processor: Callable[[str, MessageNode], Awaitable[None]],
+ ) -> bool:
+ """
+ Enqueue a node for processing.
+
+ If the tree is not busy, processing starts immediately.
+ If busy, the message is queued.
+
+ Args:
+ node_id: Node to process
+ processor: Async function to process the node
+
+ Returns:
+ True if queued, False if processing immediately
+ """
+ tree = self._repository.get_tree_for_node(node_id)
+ if not tree:
+ logger.error(f"No tree found for node {node_id}")
+ return False
+
+ return await self._processor.enqueue_and_start(tree, node_id, processor)
+
+ def get_queue_size(self, node_id: str) -> int:
+ """Get queue size for the tree containing a node."""
+ return self._repository.get_queue_size(node_id)
+
+ def get_pending_children(self, node_id: str) -> list[MessageNode]:
+ """Get all pending child nodes (recursively) of a given node."""
+ return self._repository.get_pending_children(node_id)
+
+ async def mark_node_error(
+ self,
+ node_id: str,
+ error_message: str,
+ propagate_to_children: bool = True,
+ ) -> list[MessageNode]:
+ """
+ Mark a node as ERROR and optionally propagate to pending children.
+
+ Args:
+ node_id: The node to mark as error
+ error_message: Error description
+ propagate_to_children: If True, also mark pending children as error
+
+ Returns:
+ List of all nodes marked as error (including children)
+ """
+ tree = self._repository.get_tree_for_node(node_id)
+ if not tree:
+ return []
+
+ affected = []
+ node = tree.get_node(node_id)
+ if node:
+ await tree.update_state(
+ node_id, MessageState.ERROR, error_message=error_message
+ )
+ affected.append(node)
+
+ if propagate_to_children:
+ pending_children = self._repository.get_pending_children(node_id)
+ for child in pending_children:
+ await tree.update_state(
+ child.node_id,
+ MessageState.ERROR,
+ error_message=f"Parent failed: {error_message}",
+ )
+ affected.append(child)
+
+ return affected
+
+ async def cancel_tree(self, root_id: str) -> list[MessageNode]:
+ """
+ Cancel all queued and in-progress messages in a tree.
+
+ Updates node states to ERROR and returns list of affected nodes
+ that were actually active or in the current processing queue.
+ """
+ tree = self._repository.get_tree(root_id)
+ if not tree:
+ return []
+
+ cancelled_nodes = []
+
+ cleanup_count = 0
+ async with tree.with_lock():
+ # 1. Cancel running task
+ if tree.cancel_current_task():
+ current_id = tree.current_node_id
+ if current_id:
+ node = tree.get_node(current_id)
+ if node and node.state not in (
+ MessageState.COMPLETED,
+ MessageState.ERROR,
+ ):
+ tree.set_node_error_sync(node, "Cancelled by user")
+ cancelled_nodes.append(node)
+
+ # 2. Drain queue and mark nodes as cancelled
+ queue_nodes = tree.drain_queue_and_mark_cancelled()
+ cancelled_nodes.extend(queue_nodes)
+ cancelled_ids = {n.node_id for n in cancelled_nodes}
+
+ # 3. Cleanup: Mark ANY other PENDING or IN_PROGRESS nodes as ERROR
+ for node in tree.all_nodes():
+ if (
+ node.state in (MessageState.PENDING, MessageState.IN_PROGRESS)
+ and node.node_id not in cancelled_ids
+ ):
+ tree.set_node_error_sync(node, "Stale task cleaned up")
+ cleanup_count += 1
+
+ tree.reset_processing_state()
+
+ if cancelled_nodes:
+ logger.info(
+ f"Cancelled {len(cancelled_nodes)} active nodes in tree {root_id}"
+ )
+ if cleanup_count:
+ logger.info(f"Cleaned up {cleanup_count} stale nodes in tree {root_id}")
+
+ return cancelled_nodes
+
+ async def cancel_node(self, node_id: str) -> list[MessageNode]:
+ """
+ Cancel a single node (queued or in-progress) without affecting other nodes.
+
+ - If the node is currently running, cancels the current asyncio task.
+ - If the node is queued, removes it from the queue.
+ - Marks the node as ERROR with "Cancelled by user".
+
+ Returns:
+ List containing the cancelled node if it was cancellable, else empty list.
+ """
+ tree = self._repository.get_tree_for_node(node_id)
+ if not tree:
+ return []
+
+ async with tree.with_lock():
+ node = tree.get_node(node_id)
+ if not node:
+ return []
+
+ if node.state in (MessageState.COMPLETED, MessageState.ERROR):
+ return []
+
+ if tree.is_current_node(node_id):
+ self._processor.cancel_current(tree)
+
+ try:
+ tree.remove_from_queue(node_id)
+ except Exception:
+ logger.debug(
+ "Failed to remove node from queue; will rely on state=ERROR"
+ )
+
+ tree.set_node_error_sync(node, "Cancelled by user")
+
+ return [node]
+
+ async def cancel_all(self) -> list[MessageNode]:
+ """Cancel all messages in all trees."""
+ async with self._lock:
+ root_ids = list(self._repository.tree_ids())
+ all_cancelled: list[MessageNode] = []
+ for root_id in root_ids:
+ all_cancelled.extend(await self.cancel_tree(root_id))
+ return all_cancelled
+
+ def cleanup_stale_nodes(self) -> int:
+ """
+ Mark any PENDING or IN_PROGRESS nodes in all trees as ERROR.
+ Used on startup to reconcile restored state.
+ """
+ count = 0
+ for tree in self._repository.all_trees():
+ for node in tree.all_nodes():
+ if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS):
+ tree.set_node_error_sync(node, "Lost during server restart")
+ count += 1
+ if count:
+ logger.info(f"Cleaned up {count} stale nodes during startup")
+ return count
+
+ def get_tree_count(self) -> int:
+ """Get the number of active message trees."""
+ return self._repository.tree_count()
+
+ def set_queue_update_callback(
+ self,
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None,
+ ) -> None:
+ """Set callback for queue position updates."""
+ self._processor.set_queue_update_callback(queue_update_callback)
+
+ def set_node_started_callback(
+ self,
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]] | None,
+ ) -> None:
+ """Set callback for when a queued node starts processing."""
+ self._processor.set_node_started_callback(node_started_callback)
+
+ def register_node(self, node_id: str, root_id: str) -> None:
+ """Register a node ID to a tree (for external mapping)."""
+ self._repository.register_node(node_id, root_id)
+
+ async def cancel_branch(self, branch_root_id: str) -> list[MessageNode]:
+ """
+ Cancel all PENDING/IN_PROGRESS nodes in the subtree (branch_root + descendants).
+
+ Does not call cli_manager.stop_all(). Returns list of cancelled nodes.
+ """
+ tree = self._repository.get_tree_for_node(branch_root_id)
+ if not tree:
+ return []
+
+ branch_ids = set(tree.get_descendants(branch_root_id))
+ cancelled: list[MessageNode] = []
+
+ async with tree.with_lock():
+ for nid in branch_ids:
+ node = tree.get_node(nid)
+ if not node or node.state in (
+ MessageState.COMPLETED,
+ MessageState.ERROR,
+ ):
+ continue
+
+ if tree.is_current_node(nid):
+ self._processor.cancel_current(tree)
+ tree.set_node_error_sync(node, "Cancelled by user")
+ cancelled.append(node)
+ else:
+ tree.remove_from_queue(nid)
+ tree.set_node_error_sync(node, "Cancelled by user")
+ cancelled.append(node)
+
+ if cancelled:
+ logger.info(f"Cancelled {len(cancelled)} nodes in branch {branch_root_id}")
+ return cancelled
+
+ async def remove_branch(
+ self, branch_root_id: str
+ ) -> tuple[list[MessageNode], str, bool]:
+ """
+ Remove a branch (subtree) from the tree.
+
+ If branch_root is the tree root, removes the entire tree.
+
+ Returns:
+ (removed_nodes, root_id, removed_entire_tree)
+ """
+ tree = self._repository.get_tree_for_node(branch_root_id)
+ if not tree:
+ return ([], "", False)
+
+ root_id = tree.root_id
+
+ if branch_root_id == root_id:
+ cancelled = await self.cancel_tree(root_id)
+ removed_tree = self._repository.remove_tree(root_id)
+ if removed_tree:
+ return (removed_tree.all_nodes(), root_id, True)
+ return (cancelled, root_id, True)
+
+ async with tree.with_lock():
+ removed = tree.remove_branch(branch_root_id)
+
+ self._repository.unregister_nodes([n.node_id for n in removed])
+ return (removed, root_id, False)
+
+ def get_message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]:
+ """Get all message IDs for a given platform/chat."""
+ return self._repository.get_message_ids_for_chat(platform, chat_id)
+
+ def to_dict(self) -> dict:
+ """Serialize all trees."""
+ return self._repository.to_dict()
+
+ @classmethod
+ def from_dict(
+ cls,
+ data: dict,
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None = None,
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]]
+ | None = None,
+ ) -> TreeQueueManager:
+ """Deserialize from dictionary."""
+ return cls(
+ queue_update_callback=queue_update_callback,
+ node_started_callback=node_started_callback,
+ _repository=TreeRepository.from_dict(data),
+ )
diff --git a/Claude_Code/messaging/trees/repository.py b/Claude_Code/messaging/trees/repository.py
new file mode 100644
index 0000000000000000000000000000000000000000..2dae1fbc1c7d54a2aa61879e71fc804421075801
--- /dev/null
+++ b/Claude_Code/messaging/trees/repository.py
@@ -0,0 +1,186 @@
+"""Repository for message tree data access.
+
+Provides data access layer for managing trees and node mappings.
+"""
+
+from loguru import logger
+
+from .data import MessageNode, MessageState, MessageTree
+
+
+class TreeRepository:
+ """
+ Repository for message tree data access.
+
+ Manages the storage and lookup of trees and node-to-tree mappings.
+ """
+
+ def __init__(self):
+ self._trees: dict[str, MessageTree] = {} # root_id -> tree
+ self._node_to_tree: dict[str, str] = {} # node_id -> root_id
+
+ def get_tree(self, root_id: str) -> MessageTree | None:
+ """Get a tree by its root ID."""
+ return self._trees.get(root_id)
+
+ def get_tree_for_node(self, node_id: str) -> MessageTree | None:
+ """Get the tree containing a given node."""
+ root_id = self._node_to_tree.get(node_id)
+ if not root_id:
+ return None
+ return self._trees.get(root_id)
+
+ def get_node(self, node_id: str) -> MessageNode | None:
+ """Get a node from any tree."""
+ tree = self.get_tree_for_node(node_id)
+ return tree.get_node(node_id) if tree else None
+
+ def add_tree(self, root_id: str, tree: MessageTree) -> None:
+ """Add a new tree to the repository."""
+ self._trees[root_id] = tree
+ self._node_to_tree[root_id] = root_id
+ logger.debug("TREE_REPO: add_tree root_id={}", root_id)
+
+ def register_node(self, node_id: str, root_id: str) -> None:
+ """Register a node ID to a tree."""
+ self._node_to_tree[node_id] = root_id
+ logger.debug("TREE_REPO: register_node node_id={} root_id={}", node_id, root_id)
+
+ def has_node(self, node_id: str) -> bool:
+ """Check if a node is registered in any tree."""
+ return node_id in self._node_to_tree
+
+ def tree_count(self) -> int:
+ """Get the number of trees in the repository."""
+ return len(self._trees)
+
+ def is_tree_busy(self, root_id: str) -> bool:
+ """Check if a tree is currently processing."""
+ tree = self._trees.get(root_id)
+ return tree.is_processing if tree else False
+
+ def is_node_tree_busy(self, node_id: str) -> bool:
+ """Check if the tree containing a node is busy."""
+ tree = self.get_tree_for_node(node_id)
+ return tree.is_processing if tree else False
+
+ def get_queue_size(self, node_id: str) -> int:
+ """Get queue size for the tree containing a node."""
+ tree = self.get_tree_for_node(node_id)
+ return tree.get_queue_size() if tree else 0
+
+ def resolve_parent_node_id(self, msg_id: str) -> str | None:
+ """
+ Resolve a message ID to the actual parent node ID.
+
+ Handles the case where msg_id is a status message ID
+ (which maps to the tree but isn't an actual node).
+
+ Returns:
+ The node_id to use as parent, or None if not found
+ """
+ tree = self.get_tree_for_node(msg_id)
+ if not tree:
+ return None
+
+ # Check if msg_id is an actual node
+ if tree.has_node(msg_id):
+ return msg_id
+
+ # Otherwise, it might be a status message - find the owning node
+ node = tree.find_node_by_status_message(msg_id)
+ if node:
+ return node.node_id
+
+ return None
+
+ def get_pending_children(self, node_id: str) -> list[MessageNode]:
+ """
+ Get all pending child nodes (recursively) of a given node.
+
+ Used for error propagation - when a node fails, its pending
+ children should also be marked as failed.
+ """
+ tree = self.get_tree_for_node(node_id)
+ if not tree:
+ return []
+
+ pending: list[MessageNode] = []
+ stack = [node_id]
+
+ while stack:
+ current_id = stack.pop()
+ node = tree.get_node(current_id)
+ if not node:
+ continue
+ for child_id in node.children_ids:
+ child = tree.get_node(child_id)
+ if child and child.state == MessageState.PENDING:
+ pending.append(child)
+ stack.append(child_id)
+
+ return pending
+
+ def all_trees(self) -> list[MessageTree]:
+ """Get all trees in the repository."""
+ return list(self._trees.values())
+
+ def tree_ids(self) -> list[str]:
+ """Get all tree root IDs."""
+ return list(self._trees.keys())
+
+ def unregister_nodes(self, node_ids: list[str]) -> None:
+ """Remove node IDs from the node-to-tree mapping."""
+ for nid in node_ids:
+ self._node_to_tree.pop(nid, None)
+
+ def remove_tree(self, root_id: str) -> MessageTree | None:
+ """
+ Remove a tree and all its node mappings from the repository.
+
+ Returns:
+ The removed tree, or None if not found.
+ """
+ tree = self._trees.pop(root_id, None)
+ if not tree:
+ return None
+ for node in tree.all_nodes():
+ self._node_to_tree.pop(node.node_id, None)
+ logger.debug("TREE_REPO: remove_tree root_id={}", root_id)
+ return tree
+
+ def get_message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]:
+ """Get all message IDs (incoming + status) for a given platform/chat.
+
+ Note: O(total_nodes) scan. Acceptable because this is only called
+ from /clear (user-initiated, infrequent).
+ """
+ msg_ids: set[str] = set()
+ for tree in self._trees.values():
+ for node in tree.all_nodes():
+ if str(node.incoming.platform) == str(platform) and str(
+ node.incoming.chat_id
+ ) == str(chat_id):
+ if node.incoming.message_id is not None:
+ msg_ids.add(str(node.incoming.message_id))
+ if node.status_message_id:
+ msg_ids.add(str(node.status_message_id))
+ return msg_ids
+
+ def to_dict(self) -> dict:
+ """Serialize all trees."""
+ return {
+ "trees": {rid: tree.to_dict() for rid, tree in self._trees.items()},
+ "node_to_tree": self._node_to_tree.copy(),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> TreeRepository:
+ """Deserialize from dictionary."""
+ from .data import MessageTree
+
+ repo = cls()
+ for root_id, tree_data in data.get("trees", {}).items():
+ repo._trees[root_id] = MessageTree.from_dict(tree_data)
+ repo._node_to_tree = data.get("node_to_tree", {})
+ return repo
diff --git a/Claude_Code/nvidia_nim_models.json b/Claude_Code/nvidia_nim_models.json
new file mode 100644
index 0000000000000000000000000000000000000000..8f5a3e7d079a0c60896c02d84cc9d29275cbb4fe
--- /dev/null
+++ b/Claude_Code/nvidia_nim_models.json
@@ -0,0 +1,1133 @@
+{
+ "object": "list",
+ "data": [
+ {
+ "id": "01-ai/yi-large",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "01-ai"
+ },
+ {
+ "id": "abacusai/dracarys-llama-3.1-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "abacusai"
+ },
+ {
+ "id": "adept/fuyu-8b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "adept"
+ },
+ {
+ "id": "ai21labs/jamba-1.5-large-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ai21labs"
+ },
+ {
+ "id": "ai21labs/jamba-1.5-mini-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ai21labs"
+ },
+ {
+ "id": "aisingapore/sea-lion-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "aisingapore"
+ },
+ {
+ "id": "baai/bge-m3",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "baai"
+ },
+ {
+ "id": "baichuan-inc/baichuan2-13b-chat",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "baichuan-inc"
+ },
+ {
+ "id": "bigcode/starcoder2-15b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "bigcode"
+ },
+ {
+ "id": "bigcode/starcoder2-7b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "bigcode"
+ },
+ {
+ "id": "bytedance/seed-oss-36b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "bytedance"
+ },
+ {
+ "id": "databricks/dbrx-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "databricks"
+ },
+ {
+ "id": "deepseek-ai/deepseek-coder-6.7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-r1-distill-llama-8b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-r1-distill-qwen-14b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-r1-distill-qwen-32b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-r1-distill-qwen-7b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-v3.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-v3.1-terminus",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "deepseek-ai/deepseek-v3.2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "deepseek-ai"
+ },
+ {
+ "id": "google/codegemma-1.1-7b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/codegemma-7b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/deplot",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-2-27b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-2-2b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-2-9b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-2b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-3-12b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-3-1b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-3-27b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-3-4b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-3n-e2b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-3n-e4b-it",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/gemma-7b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/paligemma",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/recurrentgemma-2b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "google/shieldgemma-9b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "google"
+ },
+ {
+ "id": "gotocompany/gemma-2-9b-cpt-sahabatai-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "gotocompany"
+ },
+ {
+ "id": "ibm/granite-3.0-3b-a800m-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ibm"
+ },
+ {
+ "id": "ibm/granite-3.0-8b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ibm"
+ },
+ {
+ "id": "ibm/granite-3.3-8b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ibm"
+ },
+ {
+ "id": "ibm/granite-34b-code-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ibm"
+ },
+ {
+ "id": "ibm/granite-8b-code-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ibm"
+ },
+ {
+ "id": "ibm/granite-guardian-3.0-8b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "ibm"
+ },
+ {
+ "id": "igenius/colosseum_355b_instruct_16k",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "igenius"
+ },
+ {
+ "id": "igenius/italia_10b_instruct_16k",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "igenius"
+ },
+ {
+ "id": "institute-of-science-tokyo/llama-3.1-swallow-70b-instruct-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "institute-of-science-tokyo"
+ },
+ {
+ "id": "institute-of-science-tokyo/llama-3.1-swallow-8b-instruct-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "institute-of-science-tokyo"
+ },
+ {
+ "id": "marin/marin-8b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "marin"
+ },
+ {
+ "id": "mediatek/breeze-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mediatek"
+ },
+ {
+ "id": "meta/codellama-70b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.1-405b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.1-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.1-8b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.2-11b-vision-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.2-1b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.2-3b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.2-90b-vision-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-3.3-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-4-maverick-17b-128e-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-4-scout-17b-16e-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama-guard-4-12b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama2-70b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama3-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "meta/llama3-8b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "meta"
+ },
+ {
+ "id": "microsoft/kosmos-2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-medium-128k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-medium-4k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-mini-128k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-mini-4k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-small-128k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-small-8k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3-vision-128k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3.5-mini-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3.5-moe-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-3.5-vision-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-4-mini-flash-reasoning",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-4-mini-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "microsoft/phi-4-multimodal-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "microsoft"
+ },
+ {
+ "id": "minimaxai/minimax-m2.5",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "minimaxai"
+ },
+ {
+ "id": "mistralai/codestral-22b-instruct-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/devstral-2-123b-instruct-2512",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/magistral-small-2506",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mamba-codestral-7b-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mathstral-7b-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/ministral-14b-instruct-2512",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-7b-instruct-v0.2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-7b-instruct-v0.3",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-large",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-large-2-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-large-3-675b-instruct-2512",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-medium-3-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-nemotron",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-small-24b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-small-3.1-24b-instruct-2503",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mistral-small-4-119b-2603",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mixtral-8x22b-instruct-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mixtral-8x22b-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "mistralai/mixtral-8x7b-instruct-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "mistralai"
+ },
+ {
+ "id": "moonshotai/kimi-k2-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "moonshotai"
+ },
+ {
+ "id": "moonshotai/kimi-k2-instruct-0905",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "moonshotai"
+ },
+ {
+ "id": "moonshotai/kimi-k2-thinking",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "moonshotai"
+ },
+ {
+ "id": "moonshotai/kimi-k2.5",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "moonshotai"
+ },
+ {
+ "id": "nv-mistralai/mistral-nemo-12b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nv-mistralai"
+ },
+ {
+ "id": "nvidia/cosmos-reason2-8b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/embed-qa-4",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/gliner-pii",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemoguard-8b-content-safety",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemoguard-8b-topic-control",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-51b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-70b-reward",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-nano-4b-v1.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-nano-8b-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-nano-vl-8b-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-safety-guard-8b-v3",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.1-nemotron-ultra-253b-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.2-nemoretriever-1b-vlm-embed-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.2-nemoretriever-300m-embed-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.2-nv-embedqa-1b-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.2-nv-embedqa-1b-v2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.3-nemotron-super-49b-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-nemotron-embed-1b-v2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama-nemotron-embed-vl-1b-v2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama3-chatqa-1.5-70b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/llama3-chatqa-1.5-8b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/mistral-nemo-minitron-8b-8k-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/mistral-nemo-minitron-8b-base",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemoretriever-parse",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-3-nano-30b-a3b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-3-super-120b-a12b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-4-340b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-4-340b-reward",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-4-mini-hindi-4b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-content-safety-reasoning-4b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-mini-4b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-nano-12b-v2-vl",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-nano-3-30b-a3b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nemotron-parse",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/neva-22b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nv-embed-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nv-embedcode-7b-v1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nv-embedqa-e5-v5",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nv-embedqa-mistral-7b-v2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nvclip",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/nvidia-nemotron-nano-9b-v2",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/riva-translate-4b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/riva-translate-4b-instruct-v1.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/streampetr",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/usdcode-llama-3.1-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "nvidia/vila",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "nvidia"
+ },
+ {
+ "id": "openai/gpt-oss-120b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "openai"
+ },
+ {
+ "id": "openai/gpt-oss-120b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "openai"
+ },
+ {
+ "id": "openai/gpt-oss-20b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "openai"
+ },
+ {
+ "id": "openai/gpt-oss-20b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "openai"
+ },
+ {
+ "id": "opengpt-x/teuken-7b-instruct-commercial-v0.4",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "opengpt-x"
+ },
+ {
+ "id": "qwen/qwen2-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen2.5-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen2.5-coder-32b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen2.5-coder-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen3-coder-480b-a35b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen3-next-80b-a3b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen3-next-80b-a3b-thinking",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen3.5-122b-a10b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwen3.5-397b-a17b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "qwen/qwq-32b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "qwen"
+ },
+ {
+ "id": "rakuten/rakutenai-7b-chat",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "rakuten"
+ },
+ {
+ "id": "rakuten/rakutenai-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "rakuten"
+ },
+ {
+ "id": "sarvamai/sarvam-m",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "sarvamai"
+ },
+ {
+ "id": "snowflake/arctic-embed-l",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "snowflake"
+ },
+ {
+ "id": "speakleash/bielik-11b-v2.3-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "speakleash"
+ },
+ {
+ "id": "speakleash/bielik-11b-v2.6-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "speakleash"
+ },
+ {
+ "id": "stepfun-ai/step-3.5-flash",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "stepfun-ai"
+ },
+ {
+ "id": "stockmark/stockmark-2-100b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "stockmark"
+ },
+ {
+ "id": "thudm/chatglm3-6b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "thudm"
+ },
+ {
+ "id": "tiiuae/falcon3-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "tiiuae"
+ },
+ {
+ "id": "tokyotech-llm/llama-3-swallow-70b-instruct-v0.1",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "tokyotech-llm"
+ },
+ {
+ "id": "upstage/solar-10.7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "upstage"
+ },
+ {
+ "id": "utter-project/eurollm-9b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "utter-project"
+ },
+ {
+ "id": "writer/palmyra-creative-122b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "writer"
+ },
+ {
+ "id": "writer/palmyra-fin-70b-32k",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "writer"
+ },
+ {
+ "id": "writer/palmyra-med-70b",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "writer"
+ },
+ {
+ "id": "writer/palmyra-med-70b-32k",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "writer"
+ },
+ {
+ "id": "yentinglin/llama-3-taiwan-70b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "yentinglin"
+ },
+ {
+ "id": "z-ai/glm4.7",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "z-ai"
+ },
+ {
+ "id": "z-ai/glm5",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "z-ai"
+ },
+ {
+ "id": "zyphra/zamba2-7b-instruct",
+ "object": "model",
+ "created": 735790403,
+ "owned_by": "zyphra"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Claude_Code/providers/__init__.py b/Claude_Code/providers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6a759e51038c412f68d4450af7dcb56343cc5a6
--- /dev/null
+++ b/Claude_Code/providers/__init__.py
@@ -0,0 +1,30 @@
+"""Providers package - implement your own provider by extending BaseProvider."""
+
+from .base import BaseProvider, ProviderConfig
+from .exceptions import (
+ APIError,
+ AuthenticationError,
+ InvalidRequestError,
+ OverloadedError,
+ ProviderError,
+ RateLimitError,
+)
+from .llamacpp import LlamaCppProvider
+from .lmstudio import LMStudioProvider
+from .nvidia_nim import NvidiaNimProvider
+from .open_router import OpenRouterProvider
+
+__all__ = [
+ "APIError",
+ "AuthenticationError",
+ "BaseProvider",
+ "InvalidRequestError",
+ "LMStudioProvider",
+ "LlamaCppProvider",
+ "NvidiaNimProvider",
+ "OpenRouterProvider",
+ "OverloadedError",
+ "ProviderConfig",
+ "ProviderError",
+ "RateLimitError",
+]
diff --git a/Claude_Code/providers/base.py b/Claude_Code/providers/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..72ba86ee72f6c99062cf592fb774085290b77467
--- /dev/null
+++ b/Claude_Code/providers/base.py
@@ -0,0 +1,47 @@
+"""Base provider interface - extend this to implement your own provider."""
+
+from abc import ABC, abstractmethod
+from collections.abc import AsyncIterator
+from typing import Any
+
+from pydantic import BaseModel
+
+
+class ProviderConfig(BaseModel):
+ """Configuration for a provider.
+
+ Base fields apply to all providers. Provider-specific parameters
+ (e.g. NIM temperature, top_p) are passed by the provider constructor.
+ """
+
+ api_key: str
+ base_url: str | None = None
+ rate_limit: int | None = None
+ rate_window: int = 60
+ max_concurrency: int = 5
+ http_read_timeout: float = 300.0
+ http_write_timeout: float = 10.0
+ http_connect_timeout: float = 2.0
+
+
+class BaseProvider(ABC):
+ """Base class for all providers. Extend this to add your own."""
+
+ def __init__(self, config: ProviderConfig):
+ self._config = config
+
+ @abstractmethod
+ async def cleanup(self) -> None:
+ """Release any resources held by this provider."""
+
+ @abstractmethod
+ async def stream_response(
+ self,
+ request: Any,
+ input_tokens: int = 0,
+ *,
+ request_id: str | None = None,
+ ) -> AsyncIterator[str]:
+ """Stream response in Anthropic SSE format."""
+ if False:
+ yield "" # Required for ty/mypy to accept abstract async generator
diff --git a/Claude_Code/providers/common/__init__.py b/Claude_Code/providers/common/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e6c12871acf22f9f5521a8e9ba3868592d59d3e
--- /dev/null
+++ b/Claude_Code/providers/common/__init__.py
@@ -0,0 +1,31 @@
+"""Shared provider utilities used by NIM, OpenRouter, and LM Studio."""
+
+from .error_mapping import append_request_id, get_user_facing_error_message, map_error
+from .heuristic_tool_parser import HeuristicToolParser
+from .message_converter import (
+ AnthropicToOpenAIConverter,
+ build_base_request_body,
+ get_block_attr,
+ get_block_type,
+)
+from .sse_builder import ContentBlockManager, SSEBuilder, map_stop_reason
+from .think_parser import ContentChunk, ContentType, ThinkTagParser
+from .utils import set_if_not_none
+
+__all__ = [
+ "AnthropicToOpenAIConverter",
+ "ContentBlockManager",
+ "ContentChunk",
+ "ContentType",
+ "HeuristicToolParser",
+ "SSEBuilder",
+ "ThinkTagParser",
+ "append_request_id",
+ "build_base_request_body",
+ "get_block_attr",
+ "get_block_type",
+ "get_user_facing_error_message",
+ "map_error",
+ "map_stop_reason",
+ "set_if_not_none",
+]
diff --git a/Claude_Code/providers/common/error_mapping.py b/Claude_Code/providers/common/error_mapping.py
new file mode 100644
index 0000000000000000000000000000000000000000..55f622e5d1d2e661b2def0e45956770970b5e608
--- /dev/null
+++ b/Claude_Code/providers/common/error_mapping.py
@@ -0,0 +1,103 @@
+"""Error mapping for OpenAI-compatible providers (NIM, OpenRouter, LM Studio)."""
+
+import httpx
+import openai
+
+from providers.exceptions import (
+ APIError,
+ AuthenticationError,
+ InvalidRequestError,
+ OverloadedError,
+ ProviderError,
+ RateLimitError,
+)
+from providers.rate_limit import GlobalRateLimiter
+
+
+def get_user_facing_error_message(
+ e: Exception,
+ *,
+ read_timeout_s: float | None = None,
+) -> str:
+ """Return a readable, non-empty error message for users."""
+ message = str(e).strip()
+ if message:
+ return message
+
+ if isinstance(e, httpx.ReadTimeout):
+ if read_timeout_s is not None:
+ return f"Provider request timed out after {read_timeout_s:g}s."
+ return "Provider request timed out."
+ if isinstance(e, httpx.ConnectTimeout):
+ return "Could not connect to provider."
+ if isinstance(e, TimeoutError):
+ if read_timeout_s is not None:
+ return f"Provider request timed out after {read_timeout_s:g}s."
+ return "Request timed out."
+
+ if isinstance(e, (RateLimitError, openai.RateLimitError)):
+ return "Provider rate limit reached. Please retry shortly."
+ if isinstance(e, (AuthenticationError, openai.AuthenticationError)):
+ return "Provider authentication failed. Check API key."
+ if isinstance(e, (InvalidRequestError, openai.BadRequestError)):
+ return "Invalid request sent to provider."
+ if isinstance(e, OverloadedError):
+ return "Provider is currently overloaded. Please retry."
+ if isinstance(e, APIError):
+ if e.status_code in (502, 503, 504):
+ return "Provider is temporarily unavailable. Please retry."
+ return "Provider API request failed."
+ if isinstance(e, ProviderError):
+ return "Provider request failed."
+
+ return "Provider request failed unexpectedly."
+
+
+def append_request_id(message: str, request_id: str | None) -> str:
+ """Append request_id suffix when available."""
+ base = message.strip() or "Provider request failed unexpectedly."
+ if request_id:
+ return f"{base} (request_id={request_id})"
+ return base
+
+
+def map_error(e: Exception) -> Exception:
+ """Map OpenAI or HTTPX exception to specific ProviderError."""
+ message = get_user_facing_error_message(e)
+
+ # Map OpenAI Specific Errors
+ if isinstance(e, openai.AuthenticationError):
+ return AuthenticationError(message, raw_error=str(e))
+ if isinstance(e, openai.RateLimitError):
+ # Trigger global rate limit block
+ GlobalRateLimiter.get_instance().set_blocked(60) # Default 60s cooldown
+ return RateLimitError(message, raw_error=str(e))
+ if isinstance(e, openai.BadRequestError):
+ return InvalidRequestError(message, raw_error=str(e))
+ if isinstance(e, openai.InternalServerError):
+ raw_message = str(e)
+ if "overloaded" in raw_message.lower() or "capacity" in raw_message.lower():
+ return OverloadedError(message, raw_error=raw_message)
+ return APIError(message, status_code=500, raw_error=str(e))
+ if isinstance(e, openai.APIError):
+ return APIError(
+ message, status_code=getattr(e, "status_code", 500), raw_error=str(e)
+ )
+
+ # Map raw HTTPX Errors
+ if isinstance(e, httpx.HTTPStatusError):
+ status = e.response.status_code
+ if status in (401, 403):
+ return AuthenticationError(message, raw_error=str(e))
+ if status == 429:
+ GlobalRateLimiter.get_instance().set_blocked(60)
+ return RateLimitError(message, raw_error=str(e))
+ if status == 400:
+ return InvalidRequestError(message, raw_error=str(e))
+ if status >= 500:
+ if status in (502, 503, 504):
+ return OverloadedError(message, raw_error=str(e))
+ return APIError(message, status_code=status, raw_error=str(e))
+ return APIError(message, status_code=status, raw_error=str(e))
+
+ return e
diff --git a/Claude_Code/providers/common/heuristic_tool_parser.py b/Claude_Code/providers/common/heuristic_tool_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..e338db52d050ba15f04c4ac85fa453d28ff91481
--- /dev/null
+++ b/Claude_Code/providers/common/heuristic_tool_parser.py
@@ -0,0 +1,226 @@
+import re
+import uuid
+from enum import Enum
+from typing import Any
+
+from loguru import logger
+
+# Some OpenAI-compatible backends/models occasionally leak internal sentinel tokens
+# into `delta.content` (e.g. "<|tool_call_end|>"). These should never be shown to
+# end users, and they can disrupt downstream parsing if left in place.
+_CONTROL_TOKEN_RE = re.compile(r"<\|[^|>]{1,80}\|>")
+_CONTROL_TOKEN_START = "<|"
+_CONTROL_TOKEN_END = "|>"
+
+
+class ParserState(Enum):
+ TEXT = 1
+ MATCHING_FUNCTION = 2
+ PARSING_PARAMETERS = 3
+
+
+class HeuristicToolParser:
+ """
+ Stateful parser that detects raw text tool calls in the format:
+ ● value...
+
+ This is used as a fallback for models that emit tool calls as text
+ instead of using the structured API.
+ """
+
+ # Class-level compiled patterns (compiled once, not per instance)
+ _FUNC_START_PATTERN = re.compile(r"●\s*]+)>")
+ _PARAM_PATTERN = re.compile(
+ r"]+)>(.*?)(?:|$)", re.DOTALL
+ )
+
+ def __init__(self):
+ self._state = ParserState.TEXT
+ self._buffer = ""
+ self._current_tool_id = None
+ self._current_function_name = None
+ self._current_parameters = {}
+
+ def _strip_control_tokens(self, text: str) -> str:
+ # Remove complete sentinel tokens. If a token is split across chunks it
+ # will be removed once the buffer contains the full token.
+ return _CONTROL_TOKEN_RE.sub("", text)
+
+ def _split_incomplete_control_token_tail(self) -> str:
+ """
+ If the buffer ends with an incomplete "<|...|>" sentinel token, keep that
+ fragment in the buffer and return the safe-to-emit prefix.
+
+ This prevents leaking raw sentinel fragments to the user when streaming.
+ """
+ start = self._buffer.rfind(_CONTROL_TOKEN_START)
+ if start == -1:
+ return ""
+ end = self._buffer.find(_CONTROL_TOKEN_END, start)
+ if end != -1:
+ return ""
+
+ prefix = self._buffer[:start]
+ self._buffer = self._buffer[start:]
+ return prefix
+
+ def feed(self, text: str) -> tuple[str, list[dict[str, Any]]]:
+ """
+ Feed text into the parser.
+ Returns a tuple of (filtered_text, detected_tool_calls).
+
+ filtered_text: Text that should be passed through as normal message content.
+ detected_tools: List of Anthropic-format tool_use blocks.
+ """
+ self._buffer += text
+ self._buffer = self._strip_control_tokens(self._buffer)
+ detected_tools = []
+ filtered_output_parts: list[str] = []
+
+ while True:
+ if self._state == ParserState.TEXT:
+ # Look for the trigger character
+ if "●" in self._buffer:
+ idx = self._buffer.find("●")
+ filtered_output_parts.append(self._buffer[:idx])
+ self._buffer = self._buffer[idx:]
+ self._state = ParserState.MATCHING_FUNCTION
+ else:
+ # Avoid emitting an incomplete "<|...|>" sentinel fragment if the
+ # token got split across streaming chunks.
+ safe_prefix = self._split_incomplete_control_token_tail()
+ if safe_prefix:
+ filtered_output_parts.append(safe_prefix)
+ break
+
+ filtered_output_parts.append(self._buffer)
+ self._buffer = ""
+ break
+
+ if self._state == ParserState.MATCHING_FUNCTION:
+ # We need enough buffer to match the function tag
+ # e.g. "● "
+ match = self._FUNC_START_PATTERN.search(self._buffer)
+ if match:
+ self._current_function_name = match.group(1).strip()
+ self._current_tool_id = f"toolu_heuristic_{uuid.uuid4().hex[:8]}"
+ self._current_parameters = {}
+
+ # Consume the function start from buffer
+ self._buffer = self._buffer[match.end() :]
+ self._state = ParserState.PARSING_PARAMETERS
+ logger.debug(
+ "Heuristic bypass: Detected start of tool call '{}'",
+ self._current_function_name,
+ )
+ else:
+ # If we have "●" but not the full tag yet, wait for more data
+ # Unless the buffer has grown too large without a match
+ if len(self._buffer) > 100:
+ # Probably not a tool call, treat as text
+ filtered_output_parts.append(self._buffer[0])
+ self._buffer = self._buffer[1:]
+ self._state = ParserState.TEXT
+ else:
+ break
+
+ if self._state == ParserState.PARSING_PARAMETERS:
+ # Look for parameters. We look for to know a param is complete.
+ # Or wait for another " in param_match.group(0):
+ # Detect any content before the parameter match and preserve it
+ pre_match_text = self._buffer[: param_match.start()]
+ if pre_match_text:
+ filtered_output_parts.append(pre_match_text)
+
+ key = param_match.group(1).strip()
+ val = param_match.group(2).strip()
+ self._current_parameters[key] = val
+ self._buffer = self._buffer[param_match.end() :]
+ else:
+ break
+
+ # Heuristic for completion:
+ # 1. We have at least one param and we see a character that doesn't belong to the format
+ # 2. Significant pause (not handled here, handled by caller via flush if needed)
+ # 3. Another ● character (start of NEXT tool call)
+
+ if "●" in self._buffer:
+ # Next tool call starting or something else, close current
+ # But first, capture any text before the ●
+ idx = self._buffer.find("●")
+ if idx > 0:
+ filtered_output_parts.append(self._buffer[:idx])
+ self._buffer = self._buffer[idx:]
+ finished_tool_call = True
+ elif len(self._buffer) > 0 and not self._buffer.strip().startswith("<"):
+ # We have text that doesn't look like a tag, and we already parsed some or are in param state
+ # Let's see if we have trailing param starts
+ if " list[dict[str, Any]]:
+ """
+ Flush any remaining tool calls in the buffer.
+ """
+ self._buffer = self._strip_control_tokens(self._buffer)
+ detected_tools = []
+ if self._state == ParserState.PARSING_PARAMETERS:
+ # Try to extract any partial parameters remaining in buffer
+ # Even without
+ partial_matches = re.finditer(
+ r"]+)>(.*)$", self._buffer, re.DOTALL
+ )
+ for m in partial_matches:
+ key = m.group(1).strip()
+ val = m.group(2).strip()
+ self._current_parameters[key] = val
+
+ detected_tools.append(
+ {
+ "type": "tool_use",
+ "id": self._current_tool_id,
+ "name": self._current_function_name,
+ "input": self._current_parameters,
+ }
+ )
+ self._state = ParserState.TEXT
+ self._buffer = ""
+
+ return detected_tools
diff --git a/Claude_Code/providers/common/message_converter.py b/Claude_Code/providers/common/message_converter.py
new file mode 100644
index 0000000000000000000000000000000000000000..b07fa3bab364223720a97d89a7aae02839b9907d
--- /dev/null
+++ b/Claude_Code/providers/common/message_converter.py
@@ -0,0 +1,226 @@
+"""Message and tool format converters."""
+
+import json
+from typing import Any
+
+
+def get_block_attr(block: Any, attr: str, default: Any = None) -> Any:
+ """Get attribute from object or dict."""
+ if hasattr(block, attr):
+ return getattr(block, attr)
+ if isinstance(block, dict):
+ return block.get(attr, default)
+ return default
+
+
+def get_block_type(block: Any) -> str | None:
+ """Get block type from object or dict."""
+ return get_block_attr(block, "type")
+
+
+class AnthropicToOpenAIConverter:
+ """Converts Anthropic message format to OpenAI format."""
+
+ @staticmethod
+ def convert_messages(
+ messages: list[Any],
+ *,
+ include_reasoning_for_openrouter: bool = False,
+ ) -> list[dict[str, Any]]:
+ """Convert a list of Anthropic messages to OpenAI format.
+
+ When include_reasoning_for_openrouter is True, assistant messages with
+ thinking blocks get reasoning_content added for OpenRouter multi-turn
+ reasoning continuation.
+ """
+ result = []
+
+ for msg in messages:
+ role = msg.role
+ content = msg.content
+
+ if isinstance(content, str):
+ result.append({"role": role, "content": content})
+ elif isinstance(content, list):
+ if role == "assistant":
+ result.extend(
+ AnthropicToOpenAIConverter._convert_assistant_message(
+ content,
+ include_reasoning_for_openrouter=include_reasoning_for_openrouter,
+ )
+ )
+ elif role == "user":
+ result.extend(
+ AnthropicToOpenAIConverter._convert_user_message(content)
+ )
+ else:
+ result.append({"role": role, "content": str(content)})
+
+ return result
+
+ @staticmethod
+ def _convert_assistant_message(
+ content: list[Any],
+ *,
+ include_reasoning_for_openrouter: bool = False,
+ ) -> list[dict[str, Any]]:
+ """Convert assistant message blocks, preserving interleaved thinking+text order."""
+ content_parts: list[str] = []
+ thinking_parts: list[str] = []
+ tool_calls: list[dict[str, Any]] = []
+
+ for block in content:
+ block_type = get_block_type(block)
+
+ if block_type == "text":
+ content_parts.append(get_block_attr(block, "text", ""))
+ elif block_type == "thinking":
+ thinking = get_block_attr(block, "thinking", "")
+ content_parts.append(f"\n{thinking}\n")
+ if include_reasoning_for_openrouter:
+ thinking_parts.append(thinking)
+ elif block_type == "tool_use":
+ tool_input = get_block_attr(block, "input", {})
+ tool_calls.append(
+ {
+ "id": get_block_attr(block, "id"),
+ "type": "function",
+ "function": {
+ "name": get_block_attr(block, "name"),
+ "arguments": json.dumps(tool_input)
+ if isinstance(tool_input, dict)
+ else str(tool_input),
+ },
+ }
+ )
+
+ content_str = "\n\n".join(content_parts)
+
+ # Ensure content is never an empty string for assistant messages
+ # NIM (especially Mistral models) requires non-empty content if there are no tool calls
+ if not content_str and not tool_calls:
+ content_str = " "
+
+ msg: dict[str, Any] = {
+ "role": "assistant",
+ "content": content_str,
+ }
+ if tool_calls:
+ msg["tool_calls"] = tool_calls
+ if include_reasoning_for_openrouter and thinking_parts:
+ msg["reasoning_content"] = "\n".join(thinking_parts)
+
+ return [msg]
+
+ @staticmethod
+ def _convert_user_message(content: list[Any]) -> list[dict[str, Any]]:
+ """Convert user message blocks (including tool results), preserving order."""
+ result: list[dict[str, Any]] = []
+ text_parts: list[str] = []
+
+ def flush_text() -> None:
+ if text_parts:
+ result.append({"role": "user", "content": "\n".join(text_parts)})
+ text_parts.clear()
+
+ for block in content:
+ block_type = get_block_type(block)
+
+ if block_type == "text":
+ text_parts.append(get_block_attr(block, "text", ""))
+ elif block_type == "tool_result":
+ flush_text()
+ tool_content = get_block_attr(block, "content", "")
+ if isinstance(tool_content, list):
+ tool_content = "\n".join(
+ item.get("text", str(item))
+ if isinstance(item, dict)
+ else str(item)
+ for item in tool_content
+ )
+ result.append(
+ {
+ "role": "tool",
+ "tool_call_id": get_block_attr(block, "tool_use_id"),
+ "content": str(tool_content) if tool_content else "",
+ }
+ )
+
+ flush_text()
+ return result
+
+ @staticmethod
+ def convert_tools(tools: list[Any]) -> list[dict[str, Any]]:
+ """Convert Anthropic tools to OpenAI format."""
+ return [
+ {
+ "type": "function",
+ "function": {
+ "name": tool.name,
+ "description": tool.description or "",
+ "parameters": tool.input_schema,
+ },
+ }
+ for tool in tools
+ ]
+
+ @staticmethod
+ def convert_system_prompt(system: Any) -> dict[str, str] | None:
+ """Convert Anthropic system prompt to OpenAI format."""
+ if isinstance(system, str):
+ return {"role": "system", "content": system}
+ elif isinstance(system, list):
+ text_parts = [
+ get_block_attr(block, "text", "")
+ for block in system
+ if get_block_type(block) == "text"
+ ]
+ if text_parts:
+ return {"role": "system", "content": "\n\n".join(text_parts).strip()}
+ return None
+
+
+def build_base_request_body(
+ request_data: Any,
+ *,
+ default_max_tokens: int | None = None,
+ include_reasoning_for_openrouter: bool = False,
+) -> dict[str, Any]:
+ """Build the common parts of an OpenAI-format request body.
+
+ Handles message conversion, system prompt, max_tokens, temperature,
+ top_p, stop sequences, tools, and tool_choice. Provider-specific
+ parameters (extra_body, penalties, NIM settings) are added by callers.
+ """
+ from providers.common.utils import set_if_not_none
+
+ messages = AnthropicToOpenAIConverter.convert_messages(
+ request_data.messages,
+ include_reasoning_for_openrouter=include_reasoning_for_openrouter,
+ )
+
+ system = getattr(request_data, "system", None)
+ if system:
+ system_msg = AnthropicToOpenAIConverter.convert_system_prompt(system)
+ if system_msg:
+ messages.insert(0, system_msg)
+
+ body: dict[str, Any] = {"model": request_data.model, "messages": messages}
+
+ max_tokens = getattr(request_data, "max_tokens", None)
+ set_if_not_none(body, "max_tokens", max_tokens or default_max_tokens)
+ set_if_not_none(body, "temperature", getattr(request_data, "temperature", None))
+ set_if_not_none(body, "top_p", getattr(request_data, "top_p", None))
+
+ stop_sequences = getattr(request_data, "stop_sequences", None)
+ if stop_sequences:
+ body["stop"] = stop_sequences
+
+ tools = getattr(request_data, "tools", None)
+ if tools:
+ body["tools"] = AnthropicToOpenAIConverter.convert_tools(tools)
+ tool_choice = getattr(request_data, "tool_choice", None)
+ if tool_choice:
+ body["tool_choice"] = tool_choice
+
+ return body
diff --git a/Claude_Code/providers/common/sse_builder.py b/Claude_Code/providers/common/sse_builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..9dd21835e49d808eb8c8748bd7ecac1fb3c46ead
--- /dev/null
+++ b/Claude_Code/providers/common/sse_builder.py
@@ -0,0 +1,390 @@
+"""SSE event builder for Anthropic-format streaming responses."""
+
+import json
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from typing import Any
+
+from loguru import logger
+
+try:
+ import tiktoken
+
+ ENCODER = tiktoken.get_encoding("cl100k_base")
+except Exception:
+ ENCODER = None
+
+
+# Map OpenAI finish_reason to Anthropic stop_reason
+STOP_REASON_MAP = {
+ "stop": "end_turn",
+ "length": "max_tokens",
+ "tool_calls": "tool_use",
+ "content_filter": "end_turn",
+}
+
+
+def map_stop_reason(openai_reason: str | None) -> str:
+ """Map OpenAI finish_reason to Anthropic stop_reason."""
+ return (
+ STOP_REASON_MAP.get(openai_reason, "end_turn") if openai_reason else "end_turn"
+ )
+
+
+@dataclass
+class ToolCallState:
+ """State for a single streaming tool call."""
+
+ block_index: int # -1 if not yet allocated
+ tool_id: str
+ name: str
+ contents: list[str] = field(default_factory=list)
+ started: bool = False
+ task_arg_buffer: str = ""
+ task_args_emitted: bool = False
+
+
+@dataclass
+class ContentBlockManager:
+ """Manages content block indices and state."""
+
+ next_index: int = 0
+ thinking_index: int = -1
+ text_index: int = -1
+ thinking_started: bool = False
+ text_started: bool = False
+ tool_states: dict[int, ToolCallState] = field(default_factory=dict)
+
+ def allocate_index(self) -> int:
+ """Allocate and return the next block index."""
+ idx = self.next_index
+ self.next_index += 1
+ return idx
+
+ def register_tool_name(self, index: int, name: str) -> None:
+ """Register or merge a streaming tool name fragment.
+
+ Handles providers that stream names as fragments and those that
+ resend the full name on every chunk.
+ """
+ if index not in self.tool_states:
+ self.tool_states[index] = ToolCallState(
+ block_index=-1, tool_id="", name=name
+ )
+ return
+ state = self.tool_states[index]
+ prev = state.name
+ if not prev or name.startswith(prev):
+ state.name = name
+ elif not prev.startswith(name):
+ state.name = prev + name
+
+ def buffer_task_args(self, index: int, args: str) -> dict | None:
+ """Buffer Task tool args and return parsed JSON when complete.
+
+ Returns the parsed (and patched) args dict once the buffer forms
+ valid JSON, or None if still accumulating.
+ """
+ state = self.tool_states.get(index)
+ if state is None or state.task_args_emitted:
+ return None
+
+ state.task_arg_buffer += args
+ try:
+ args_json = json.loads(state.task_arg_buffer)
+ except Exception:
+ return None
+
+ if args_json.get("run_in_background") is not False:
+ args_json["run_in_background"] = False
+
+ state.task_args_emitted = True
+ state.task_arg_buffer = ""
+ return args_json
+
+ def flush_task_arg_buffers(self) -> list[tuple[int, str]]:
+ """Flush any remaining Task arg buffers. Returns (tool_index, json_str) pairs."""
+ results: list[tuple[int, str]] = []
+ for tool_index, state in list(self.tool_states.items()):
+ if not state.task_arg_buffer or state.task_args_emitted:
+ continue
+
+ out = "{}"
+ try:
+ args_json = json.loads(state.task_arg_buffer)
+ if args_json.get("run_in_background") is not False:
+ args_json["run_in_background"] = False
+ out = json.dumps(args_json)
+ except Exception as e:
+ prefix = state.task_arg_buffer[:120]
+ logger.warning(
+ "Task args invalid JSON (id={} len={} prefix={!r}): {}",
+ state.tool_id or "unknown",
+ len(state.task_arg_buffer),
+ prefix,
+ e,
+ )
+
+ state.task_args_emitted = True
+ state.task_arg_buffer = ""
+ results.append((tool_index, out))
+ return results
+
+
+class SSEBuilder:
+ """Builder for Anthropic SSE streaming events."""
+
+ def __init__(self, message_id: str, model: str, input_tokens: int = 0):
+ self.message_id = message_id
+ self.model = model
+ self.input_tokens = input_tokens
+ self.blocks = ContentBlockManager()
+ self._accumulated_text_parts: list[str] = []
+ self._accumulated_reasoning_parts: list[str] = []
+
+ def _format_event(self, event_type: str, data: dict[str, Any]) -> str:
+ """Format as SSE string."""
+ event_str = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
+ logger.debug("SSE_EVENT: {} - {}", event_type, event_str.strip())
+ return event_str
+
+ # Message lifecycle events
+ def message_start(self) -> str:
+ """Generate message_start event."""
+ usage = {"input_tokens": self.input_tokens, "output_tokens": 1}
+ return self._format_event(
+ "message_start",
+ {
+ "type": "message_start",
+ "message": {
+ "id": self.message_id,
+ "type": "message",
+ "role": "assistant",
+ "content": [],
+ "model": self.model,
+ "stop_reason": None,
+ "stop_sequence": None,
+ "usage": usage,
+ },
+ },
+ )
+
+ def message_delta(self, stop_reason: str, output_tokens: int) -> str:
+ """Generate message_delta event with stop reason."""
+ return self._format_event(
+ "message_delta",
+ {
+ "type": "message_delta",
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
+ "usage": {
+ "input_tokens": self.input_tokens,
+ "output_tokens": output_tokens,
+ },
+ },
+ )
+
+ def message_stop(self) -> str:
+ """Generate message_stop event."""
+ return self._format_event("message_stop", {"type": "message_stop"})
+
+ # Content block events
+ def content_block_start(self, index: int, block_type: str, **kwargs) -> str:
+ """Generate content_block_start event."""
+ content_block: dict[str, Any] = {"type": block_type}
+ if block_type == "thinking":
+ content_block["thinking"] = kwargs.get("thinking", "")
+ elif block_type == "text":
+ content_block["text"] = kwargs.get("text", "")
+ elif block_type == "tool_use":
+ content_block["id"] = kwargs.get("id", "")
+ content_block["name"] = kwargs.get("name", "")
+ content_block["input"] = kwargs.get("input", {})
+
+ return self._format_event(
+ "content_block_start",
+ {
+ "type": "content_block_start",
+ "index": index,
+ "content_block": content_block,
+ },
+ )
+
+ def content_block_delta(self, index: int, delta_type: str, content: str) -> str:
+ """Generate content_block_delta event."""
+ delta: dict[str, Any] = {"type": delta_type}
+ if delta_type == "thinking_delta":
+ delta["thinking"] = content
+ elif delta_type == "text_delta":
+ delta["text"] = content
+ elif delta_type == "input_json_delta":
+ delta["partial_json"] = content
+
+ return self._format_event(
+ "content_block_delta",
+ {
+ "type": "content_block_delta",
+ "index": index,
+ "delta": delta,
+ },
+ )
+
+ def content_block_stop(self, index: int) -> str:
+ """Generate content_block_stop event."""
+ return self._format_event(
+ "content_block_stop",
+ {
+ "type": "content_block_stop",
+ "index": index,
+ },
+ )
+
+ # High-level helpers for thinking blocks
+ def start_thinking_block(self) -> str:
+ """Start a thinking block, allocating index."""
+ self.blocks.thinking_index = self.blocks.allocate_index()
+ self.blocks.thinking_started = True
+ return self.content_block_start(self.blocks.thinking_index, "thinking")
+
+ def emit_thinking_delta(self, content: str) -> str:
+ """Emit thinking content delta."""
+ self._accumulated_reasoning_parts.append(content)
+ return self.content_block_delta(
+ self.blocks.thinking_index, "thinking_delta", content
+ )
+
+ def stop_thinking_block(self) -> str:
+ """Stop the current thinking block."""
+ self.blocks.thinking_started = False
+ return self.content_block_stop(self.blocks.thinking_index)
+
+ # High-level helpers for text blocks
+ def start_text_block(self) -> str:
+ """Start a text block, allocating index."""
+ self.blocks.text_index = self.blocks.allocate_index()
+ self.blocks.text_started = True
+ return self.content_block_start(self.blocks.text_index, "text")
+
+ def emit_text_delta(self, content: str) -> str:
+ """Emit text content delta."""
+ self._accumulated_text_parts.append(content)
+ return self.content_block_delta(self.blocks.text_index, "text_delta", content)
+
+ def stop_text_block(self) -> str:
+ """Stop the current text block."""
+ self.blocks.text_started = False
+ return self.content_block_stop(self.blocks.text_index)
+
+ # High-level helpers for tool blocks
+ def start_tool_block(self, tool_index: int, tool_id: str, name: str) -> str:
+ """Start a tool_use block."""
+ block_idx = self.blocks.allocate_index()
+ if tool_index in self.blocks.tool_states:
+ state = self.blocks.tool_states[tool_index]
+ state.block_index = block_idx
+ state.tool_id = tool_id
+ state.started = True
+ else:
+ self.blocks.tool_states[tool_index] = ToolCallState(
+ block_index=block_idx,
+ tool_id=tool_id,
+ name=name,
+ started=True,
+ )
+ return self.content_block_start(block_idx, "tool_use", id=tool_id, name=name)
+
+ def emit_tool_delta(self, tool_index: int, partial_json: str) -> str:
+ """Emit tool input delta."""
+ state = self.blocks.tool_states[tool_index]
+ state.contents.append(partial_json)
+ return self.content_block_delta(
+ state.block_index, "input_json_delta", partial_json
+ )
+
+ def stop_tool_block(self, tool_index: int) -> str:
+ """Stop a tool block."""
+ block_idx = self.blocks.tool_states[tool_index].block_index
+ return self.content_block_stop(block_idx)
+
+ # State management helpers
+ def ensure_thinking_block(self) -> Iterator[str]:
+ """Ensure a thinking block is started, closing text block if needed."""
+ if self.blocks.text_started:
+ yield self.stop_text_block()
+ if not self.blocks.thinking_started:
+ yield self.start_thinking_block()
+
+ def ensure_text_block(self) -> Iterator[str]:
+ """Ensure a text block is started, closing thinking block if needed."""
+ if self.blocks.thinking_started:
+ yield self.stop_thinking_block()
+ if not self.blocks.text_started:
+ yield self.start_text_block()
+
+ def close_content_blocks(self) -> Iterator[str]:
+ """Close thinking and text blocks (before tool calls)."""
+ if self.blocks.thinking_started:
+ yield self.stop_thinking_block()
+ if self.blocks.text_started:
+ yield self.stop_text_block()
+
+ def close_all_blocks(self) -> Iterator[str]:
+ """Close all open blocks (thinking, text, tools)."""
+ if self.blocks.thinking_started:
+ yield self.stop_thinking_block()
+ if self.blocks.text_started:
+ yield self.stop_text_block()
+ for tool_index, state in list(self.blocks.tool_states.items()):
+ if state.started:
+ yield self.stop_tool_block(tool_index)
+
+ # Error handling
+ def emit_error(self, error_message: str) -> Iterator[str]:
+ """Emit an error as a text block."""
+ error_index = self.blocks.allocate_index()
+ yield self.content_block_start(error_index, "text")
+ yield self.content_block_delta(error_index, "text_delta", error_message)
+ yield self.content_block_stop(error_index)
+
+ # Accumulated content access
+ @property
+ def accumulated_text(self) -> str:
+ """Get accumulated text content."""
+ return "".join(self._accumulated_text_parts)
+
+ @property
+ def accumulated_reasoning(self) -> str:
+ """Get accumulated reasoning content."""
+ return "".join(self._accumulated_reasoning_parts)
+
+ def estimate_output_tokens(self) -> int:
+ """Estimate output tokens from accumulated content."""
+ accumulated_text = self.accumulated_text
+ accumulated_reasoning = self.accumulated_reasoning
+ if ENCODER:
+ text_tokens = len(ENCODER.encode(accumulated_text))
+ reasoning_tokens = len(ENCODER.encode(accumulated_reasoning))
+ # Tool calls are harder to tokenize exactly without reconstruction, but we can approximate
+ # by tokenizing the json dumps of tool contents
+ tool_tokens = 0
+ started_tool_count = 0
+ for state in self.blocks.tool_states.values():
+ tool_tokens += len(ENCODER.encode(state.name))
+ tool_tokens += len(ENCODER.encode("".join(state.contents)))
+ tool_tokens += 15 # Control tokens overhead per tool
+ if state.started:
+ started_tool_count += 1
+
+ # Per-block overhead (~4 tokens per content block)
+ block_count = (
+ (1 if accumulated_reasoning else 0)
+ + (1 if accumulated_text else 0)
+ + started_tool_count
+ )
+ block_overhead = block_count * 4
+
+ return text_tokens + reasoning_tokens + tool_tokens + block_overhead
+
+ text_tokens = len(accumulated_text) // 4
+ reasoning_tokens = len(accumulated_reasoning) // 4
+ tool_tokens = sum(1 for s in self.blocks.tool_states.values() if s.started) * 50
+ return text_tokens + reasoning_tokens + tool_tokens
diff --git a/Claude_Code/providers/common/text.py b/Claude_Code/providers/common/text.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce89627ea32a686c087542c5de45e76b42eceb81
--- /dev/null
+++ b/Claude_Code/providers/common/text.py
@@ -0,0 +1,17 @@
+"""Shared text extraction utilities."""
+
+from typing import Any
+
+
+def extract_text_from_content(content: Any) -> str:
+ """Extract concatenated text from message content (str or list of content blocks)."""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = []
+ for block in content:
+ text = getattr(block, "text", "")
+ if text and isinstance(text, str):
+ parts.append(text)
+ return "".join(parts)
+ return ""
diff --git a/Claude_Code/providers/common/think_parser.py b/Claude_Code/providers/common/think_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..2deed993d03f6dec0250a71234f321d457fed9c2
--- /dev/null
+++ b/Claude_Code/providers/common/think_parser.py
@@ -0,0 +1,165 @@
+"""Think tag parser for extracting reasoning content from responses."""
+
+from collections.abc import Iterator
+from dataclasses import dataclass
+from enum import Enum
+
+
+class ContentType(Enum):
+ """Type of content chunk."""
+
+ TEXT = "text"
+ THINKING = "thinking"
+
+
+@dataclass
+class ContentChunk:
+ """A chunk of parsed content."""
+
+ type: ContentType
+ content: str
+
+
+class ThinkTagParser:
+ """
+ Streaming parser for ... tags.
+
+ Handles partial tags at chunk boundaries by buffering.
+ """
+
+ OPEN_TAG = ""
+ CLOSE_TAG = ""
+ OPEN_TAG_LEN = 7
+ CLOSE_TAG_LEN = 8
+
+ def __init__(self):
+ self._buffer: str = ""
+ self._in_think_tag: bool = False
+
+ @property
+ def in_think_mode(self) -> bool:
+ """Whether currently inside a think tag."""
+ return self._in_think_tag
+
+ def feed(self, content: str) -> Iterator[ContentChunk]:
+ """
+ Feed content and yield parsed chunks.
+
+ Handles partial tags by buffering content near potential tag boundaries.
+ Uses an iterative loop instead of mutual recursion to avoid stack overflow
+ on inputs with many consecutive think tags.
+ """
+ self._buffer += content
+
+ while self._buffer:
+ prev_len = len(self._buffer)
+ if not self._in_think_tag:
+ chunk = self._parse_outside_think()
+ else:
+ chunk = self._parse_inside_think()
+
+ if chunk:
+ yield chunk
+ elif len(self._buffer) == prev_len:
+ # No progress: waiting for more data
+ break
+
+ def _parse_outside_think(self) -> ContentChunk | None:
+ """Parse content outside think tags."""
+ think_start = self._buffer.find(self.OPEN_TAG)
+ orphan_close = self._buffer.find(self.CLOSE_TAG)
+
+ # Handle orphan - strip it (Step Fun AI sends reasoning via
+ # reasoning_content but may leak closing tags in content)
+ if orphan_close != -1 and (think_start == -1 or orphan_close < think_start):
+ pre_orphan = self._buffer[:orphan_close]
+ self._buffer = self._buffer[orphan_close + self.CLOSE_TAG_LEN :]
+ if pre_orphan:
+ return ContentChunk(ContentType.TEXT, pre_orphan)
+ # Buffer shrunk; the feed() loop will continue parsing
+ return None
+
+ if think_start == -1:
+ # No tag found - check for partial tag at end
+ # We buffer any trailing '<' and subsequent characters that could be part of or
+ last_bracket = self._buffer.rfind("<")
+ if last_bracket != -1:
+ potential_tag = self._buffer[last_bracket:]
+ tag_len = len(potential_tag)
+ # Check if could be partial or
+ if (
+ tag_len < self.OPEN_TAG_LEN
+ and self.OPEN_TAG.startswith(potential_tag)
+ ) or (
+ tag_len < self.CLOSE_TAG_LEN
+ and self.CLOSE_TAG.startswith(potential_tag)
+ ):
+ emit = self._buffer[:last_bracket]
+ self._buffer = self._buffer[last_bracket:]
+ if emit:
+ return ContentChunk(ContentType.TEXT, emit)
+ return None
+
+ # No partial tag found or it's irrelevant
+ emit = self._buffer
+ self._buffer = ""
+ if emit:
+ return ContentChunk(ContentType.TEXT, emit)
+ return None
+ else:
+ # Found tag
+ pre_think = self._buffer[:think_start]
+ self._buffer = self._buffer[think_start + self.OPEN_TAG_LEN :]
+ self._in_think_tag = True
+ if pre_think:
+ return ContentChunk(ContentType.TEXT, pre_think)
+ # Buffer shrunk (consumed ); the feed() loop will continue
+ # parsing inside the think tag on the next iteration
+ return None
+
+ def _parse_inside_think(self) -> ContentChunk | None:
+ """Parse content inside think tags."""
+ think_end = self._buffer.find(self.CLOSE_TAG)
+
+ if think_end == -1:
+ # No closing tag - check for partial at end
+ last_bracket = self._buffer.rfind("<")
+ if (
+ last_bracket != -1
+ and len(self._buffer) - last_bracket < self.CLOSE_TAG_LEN
+ ):
+ # Check if the partial string could be the start of
+ potential_tag = self._buffer[last_bracket:]
+ if self.CLOSE_TAG.startswith(potential_tag):
+ emit = self._buffer[:last_bracket]
+ self._buffer = self._buffer[last_bracket:]
+ if emit:
+ return ContentChunk(ContentType.THINKING, emit)
+ return None
+
+ emit = self._buffer
+ self._buffer = ""
+ if emit:
+ return ContentChunk(ContentType.THINKING, emit)
+ return None
+ else:
+ # Found tag
+ thinking_content = self._buffer[:think_end]
+ self._buffer = self._buffer[think_end + self.CLOSE_TAG_LEN :]
+ self._in_think_tag = False
+ if thinking_content:
+ return ContentChunk(ContentType.THINKING, thinking_content)
+ # Buffer shrunk (consumed ); the feed() loop will continue
+ # parsing outside the think tag on the next iteration
+ return None
+
+ def flush(self) -> ContentChunk | None:
+ """Flush any remaining buffered content."""
+ if self._buffer:
+ chunk_type = (
+ ContentType.THINKING if self._in_think_tag else ContentType.TEXT
+ )
+ content = self._buffer
+ self._buffer = ""
+ return ContentChunk(chunk_type, content)
+ return None
diff --git a/Claude_Code/providers/common/utils.py b/Claude_Code/providers/common/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f290ea8a5619127857e2a80650ec9c7447740e5
--- /dev/null
+++ b/Claude_Code/providers/common/utils.py
@@ -0,0 +1,9 @@
+"""Shared utility helpers for provider request builders."""
+
+from typing import Any
+
+
+def set_if_not_none(body: dict[str, Any], key: str, value: Any) -> None:
+ """Set body[key] = value only when value is not None."""
+ if value is not None:
+ body[key] = value
diff --git a/Claude_Code/providers/exceptions.py b/Claude_Code/providers/exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0099864bba674eef588d12b89e01361b0d88674
--- /dev/null
+++ b/Claude_Code/providers/exceptions.py
@@ -0,0 +1,90 @@
+"""Unified exception hierarchy for providers."""
+
+from typing import Any
+
+
+class ProviderError(Exception):
+ """Base exception for all provider errors."""
+
+ def __init__(
+ self,
+ message: str,
+ status_code: int = 500,
+ error_type: str = "api_error",
+ raw_error: Any = None,
+ ):
+ super().__init__(message)
+ self.message = message
+ self.status_code = status_code
+ self.error_type = error_type
+ self.raw_error = raw_error
+
+ def to_anthropic_format(self) -> dict:
+ """Convert to Anthropic-compatible error response."""
+ return {
+ "type": "error",
+ "error": {
+ "type": self.error_type,
+ "message": self.message,
+ },
+ }
+
+
+class AuthenticationError(ProviderError):
+ """Raised when API key is invalid or missing."""
+
+ def __init__(self, message: str, raw_error: Any = None):
+ super().__init__(
+ message,
+ status_code=401,
+ error_type="authentication_error",
+ raw_error=raw_error,
+ )
+
+
+class InvalidRequestError(ProviderError):
+ """Raised when the request parameters are invalid."""
+
+ def __init__(self, message: str, raw_error: Any = None):
+ super().__init__(
+ message,
+ status_code=400,
+ error_type="invalid_request_error",
+ raw_error=raw_error,
+ )
+
+
+class RateLimitError(ProviderError):
+ """Raised when rate limit is exceeded."""
+
+ def __init__(self, message: str, raw_error: Any = None):
+ super().__init__(
+ message,
+ status_code=429,
+ error_type="rate_limit_error",
+ raw_error=raw_error,
+ )
+
+
+class OverloadedError(ProviderError):
+ """Raised when the provider is overloaded."""
+
+ def __init__(self, message: str, raw_error: Any = None):
+ super().__init__(
+ message,
+ status_code=529,
+ error_type="overloaded_error",
+ raw_error=raw_error,
+ )
+
+
+class APIError(ProviderError):
+ """Raised when the provider returns a generic API error."""
+
+ def __init__(self, message: str, status_code: int = 500, raw_error: Any = None):
+ super().__init__(
+ message,
+ status_code=status_code,
+ error_type="api_error",
+ raw_error=raw_error,
+ )
diff --git a/Claude_Code/providers/llamacpp/__init__.py b/Claude_Code/providers/llamacpp/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..5316f23ab0987c1e595a6d824e3f0fd9514bd13e
--- /dev/null
+++ b/Claude_Code/providers/llamacpp/__init__.py
@@ -0,0 +1,3 @@
+from .client import LlamaCppProvider
+
+__all__ = ["LlamaCppProvider"]
diff --git a/Claude_Code/providers/llamacpp/client.py b/Claude_Code/providers/llamacpp/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa8683274a71dc1c9eca56f05954c853d0fdabc7
--- /dev/null
+++ b/Claude_Code/providers/llamacpp/client.py
@@ -0,0 +1,147 @@
+"""Llama.cpp provider implementation."""
+
+import json
+from collections.abc import AsyncIterator
+from typing import Any
+
+import httpx
+from loguru import logger
+
+from providers.base import BaseProvider, ProviderConfig
+from providers.common import get_user_facing_error_message, map_error
+from providers.rate_limit import GlobalRateLimiter
+
+LLAMACPP_DEFAULT_BASE_URL = "http://localhost:8080/v1"
+
+
+class LlamaCppProvider(BaseProvider):
+ """Llama.cpp provider using native Anthropic Messages API endpoint."""
+
+ def __init__(self, config: ProviderConfig):
+ super().__init__(config)
+ self._provider_name = "LLAMACPP"
+ self._base_url = (config.base_url or LLAMACPP_DEFAULT_BASE_URL).rstrip("/")
+
+ # We need the base URL without /v1 if the user provided it with /v1,
+ # so we can append /v1/messages safely.
+ # Actually, if they provided http://localhost:8080/v1, we can just use
+ # {base_url}/messages which becomes http://localhost:8080/v1/messages
+
+ self._global_rate_limiter = GlobalRateLimiter.get_instance(
+ rate_limit=config.rate_limit,
+ rate_window=config.rate_window,
+ max_concurrency=config.max_concurrency,
+ )
+ self._client = httpx.AsyncClient(
+ base_url=self._base_url,
+ timeout=httpx.Timeout(
+ config.http_read_timeout,
+ connect=config.http_connect_timeout,
+ read=config.http_read_timeout,
+ write=config.http_write_timeout,
+ ),
+ )
+
+ async def cleanup(self) -> None:
+ """Release HTTP client resources."""
+ await self._client.aclose()
+
+ async def stream_response(
+ self,
+ request: Any,
+ input_tokens: int = 0,
+ *,
+ request_id: str | None = None,
+ ) -> AsyncIterator[str]:
+ """Stream response natively via Llama.cpp's Anthropic-compatible endpoint."""
+ tag = self._provider_name
+ req_tag = f" request_id={request_id}" if request_id else ""
+
+ # Dump the Anthropic Pydantic model directly into a dict
+ body = request.model_dump(exclude_none=True)
+
+ # Remove extra_body, original_model, resolved_provider_model which are internal
+ body.pop("extra_body", None)
+ body.pop("original_model", None)
+ body.pop("resolved_provider_model", None)
+
+ # Translate internal ThinkingConfig to Anthropic API schema
+ if "thinking" in body:
+ thinking_cfg = body.pop("thinking")
+ if isinstance(thinking_cfg, dict) and thinking_cfg.get("enabled"):
+ # Anthropic API requires a budget_tokens value when enabled
+ body["thinking"] = {"type": "enabled"}
+
+ # Ensure max_tokens is present (Claude API requires it)
+ if "max_tokens" not in body:
+ body["max_tokens"] = 81920
+
+ logger.info(
+ "{}_STREAM:{} natively passing Anthropic request to llama.cpp model={} msgs={} tools={}",
+ tag,
+ req_tag,
+ body.get("model"),
+ len(body.get("messages", [])),
+ len(body.get("tools", [])),
+ )
+
+ async with self._global_rate_limiter.concurrency_slot():
+ try:
+ # We use execute_with_retry around the streaming request context
+ # To do this safely with httpx streaming, we await the chunk stream
+
+ async def _make_request():
+ request_obj = self._client.build_request(
+ "POST",
+ "/messages",
+ json=body,
+ headers={"Content-Type": "application/json"},
+ )
+ return await self._client.send(request_obj, stream=True)
+
+ response = await self._global_rate_limiter.execute_with_retry(
+ _make_request
+ )
+
+ if response.status_code != 200:
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ text = await response.aread()
+ logger.error(
+ "{}_ERROR:{} HTTP {}: {}",
+ tag,
+ req_tag,
+ response.status_code,
+ text.decode("utf-8", errors="replace"),
+ )
+ raise e
+
+ async for line in response.aiter_lines():
+ if line:
+ yield f"{line}\n"
+ else:
+ yield "\n"
+
+ except Exception as e:
+ logger.error("{}_ERROR:{} {}: {}", tag, req_tag, type(e).__name__, e)
+ mapped_e = map_error(e)
+ error_message = get_user_facing_error_message(
+ mapped_e, read_timeout_s=self._config.http_read_timeout
+ )
+ if request_id:
+ error_message += f"\nRequest ID: {request_id}"
+
+ logger.info(
+ "{}_STREAM: Emitting native SSE error event for {}{}",
+ tag,
+ type(e).__name__,
+ req_tag,
+ )
+
+ # Emit an Anthropic-compatible error event
+ error_event = {
+ "type": "error",
+ "error": {"type": "api_error", "message": error_message},
+ }
+ yield f"event: error\ndata: {json.dumps(error_event)}\n\n"
diff --git a/Claude_Code/providers/lmstudio/__init__.py b/Claude_Code/providers/lmstudio/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbcd0f9e3ebada7033dcf668f6fd5402d3fab599
--- /dev/null
+++ b/Claude_Code/providers/lmstudio/__init__.py
@@ -0,0 +1,5 @@
+"""LM Studio provider - Anthropic-compatible local API."""
+
+from .client import LMStudioProvider
+
+__all__ = ["LMStudioProvider"]
diff --git a/Claude_Code/providers/lmstudio/client.py b/Claude_Code/providers/lmstudio/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..7b5dbdf0cffd390bed6a57eddd62ca80dabd6eaf
--- /dev/null
+++ b/Claude_Code/providers/lmstudio/client.py
@@ -0,0 +1,147 @@
+"""LM Studio provider implementation."""
+
+import json
+from collections.abc import AsyncIterator
+from typing import Any
+
+import httpx
+from loguru import logger
+
+from providers.base import BaseProvider, ProviderConfig
+from providers.common import get_user_facing_error_message, map_error
+from providers.rate_limit import GlobalRateLimiter
+
+LMSTUDIO_DEFAULT_BASE_URL = "http://localhost:1234/v1"
+
+
+class LMStudioProvider(BaseProvider):
+ """LM Studio provider using native Anthropic Messages API endpoint."""
+
+ def __init__(self, config: ProviderConfig):
+ super().__init__(config)
+ self._provider_name = "LMSTUDIO"
+ self._base_url = (config.base_url or LMSTUDIO_DEFAULT_BASE_URL).rstrip("/")
+
+ # We need the base URL without /v1 if the user provided it with /v1,
+ # so we can append /v1/messages safely.
+ # Actually, if they provided http://localhost:1234/v1, we can just use
+ # {base_url}/messages which becomes http://localhost:1234/v1/messages
+
+ self._global_rate_limiter = GlobalRateLimiter.get_instance(
+ rate_limit=config.rate_limit,
+ rate_window=config.rate_window,
+ max_concurrency=config.max_concurrency,
+ )
+ self._client = httpx.AsyncClient(
+ base_url=self._base_url,
+ timeout=httpx.Timeout(
+ config.http_read_timeout,
+ connect=config.http_connect_timeout,
+ read=config.http_read_timeout,
+ write=config.http_write_timeout,
+ ),
+ )
+
+ async def cleanup(self) -> None:
+ """Release HTTP client resources."""
+ await self._client.aclose()
+
+ async def stream_response(
+ self,
+ request: Any,
+ input_tokens: int = 0,
+ *,
+ request_id: str | None = None,
+ ) -> AsyncIterator[str]:
+ """Stream response natively via LM Studio's Anthropic-compatible endpoint."""
+ tag = self._provider_name
+ req_tag = f" request_id={request_id}" if request_id else ""
+
+ # Dump the Anthropic Pydantic model directly into a dict
+ body = request.model_dump(exclude_none=True)
+
+ # Remove extra_body, original_model, resolved_provider_model which are internal
+ body.pop("extra_body", None)
+ body.pop("original_model", None)
+ body.pop("resolved_provider_model", None)
+
+ # Translate internal ThinkingConfig to Anthropic API schema
+ if "thinking" in body:
+ thinking_cfg = body.pop("thinking")
+ if isinstance(thinking_cfg, dict) and thinking_cfg.get("enabled"):
+ # Anthropic API requires a budget_tokens value when enabled
+ body["thinking"] = {"type": "enabled"}
+
+ # Ensure max_tokens is present (Claude API requires it)
+ if "max_tokens" not in body:
+ body["max_tokens"] = 81920
+
+ logger.info(
+ "{}_STREAM:{} natively passing Anthropic request to LMStudio model={} msgs={} tools={}",
+ tag,
+ req_tag,
+ body.get("model"),
+ len(body.get("messages", [])),
+ len(body.get("tools", [])),
+ )
+
+ async with self._global_rate_limiter.concurrency_slot():
+ try:
+ # We use execute_with_retry around the streaming request context
+ # To do this safely with httpx streaming, we await the chunk stream
+
+ async def _make_request():
+ request_obj = self._client.build_request(
+ "POST",
+ "/messages",
+ json=body,
+ headers={"Content-Type": "application/json"},
+ )
+ return await self._client.send(request_obj, stream=True)
+
+ response = await self._global_rate_limiter.execute_with_retry(
+ _make_request
+ )
+
+ if response.status_code != 200:
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ text = await response.aread()
+ logger.error(
+ "{}_ERROR:{} HTTP {}: {}",
+ tag,
+ req_tag,
+ response.status_code,
+ text.decode("utf-8", errors="replace"),
+ )
+ raise e
+
+ async for line in response.aiter_lines():
+ if line:
+ yield f"{line}\n"
+ else:
+ yield "\n"
+
+ except Exception as e:
+ logger.error("{}_ERROR:{} {}: {}", tag, req_tag, type(e).__name__, e)
+ mapped_e = map_error(e)
+ error_message = get_user_facing_error_message(
+ mapped_e, read_timeout_s=self._config.http_read_timeout
+ )
+ if request_id:
+ error_message += f"\nRequest ID: {request_id}"
+
+ logger.info(
+ "{}_STREAM: Emitting native SSE error event for {}{}",
+ tag,
+ type(e).__name__,
+ req_tag,
+ )
+
+ # Emit an Anthropic-compatible error event
+ error_event = {
+ "type": "error",
+ "error": {"type": "api_error", "message": error_message},
+ }
+ yield f"event: error\ndata: {json.dumps(error_event)}\n\n"
diff --git a/Claude_Code/providers/nvidia_nim/__init__.py b/Claude_Code/providers/nvidia_nim/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0a410f61b48e142ccb1b4cdc9a399347da91b13
--- /dev/null
+++ b/Claude_Code/providers/nvidia_nim/__init__.py
@@ -0,0 +1,5 @@
+"""NVIDIA NIM provider package."""
+
+from .client import NVIDIA_NIM_BASE_URL, NvidiaNimProvider
+
+__all__ = ["NVIDIA_NIM_BASE_URL", "NvidiaNimProvider"]
diff --git a/Claude_Code/providers/nvidia_nim/client.py b/Claude_Code/providers/nvidia_nim/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd578631b1ef57d3d53bfc486f0d034da535a6ba
--- /dev/null
+++ b/Claude_Code/providers/nvidia_nim/client.py
@@ -0,0 +1,28 @@
+"""NVIDIA NIM provider implementation."""
+
+from typing import Any
+
+from config.nim import NimSettings
+from providers.base import ProviderConfig
+from providers.openai_compat import OpenAICompatibleProvider
+
+from .request import build_request_body
+
+NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
+
+
+class NvidiaNimProvider(OpenAICompatibleProvider):
+ """NVIDIA NIM provider using official OpenAI client."""
+
+ def __init__(self, config: ProviderConfig, *, nim_settings: NimSettings):
+ super().__init__(
+ config,
+ provider_name="NIM",
+ base_url=config.base_url or NVIDIA_NIM_BASE_URL,
+ api_key=config.api_key,
+ )
+ self._nim_settings = nim_settings
+
+ def _build_request_body(self, request: Any) -> dict:
+ """Internal helper for tests and shared building."""
+ return build_request_body(request, self._nim_settings)
diff --git a/Claude_Code/providers/nvidia_nim/request.py b/Claude_Code/providers/nvidia_nim/request.py
new file mode 100644
index 0000000000000000000000000000000000000000..067dfd9f5663f535f810f2da8a1949b827086dec
--- /dev/null
+++ b/Claude_Code/providers/nvidia_nim/request.py
@@ -0,0 +1,93 @@
+"""Request builder for NVIDIA NIM provider."""
+
+from typing import Any
+
+from loguru import logger
+
+from config.nim import NimSettings
+from providers.common.message_converter import build_base_request_body
+from providers.common.utils import set_if_not_none
+
+
+def _set_extra(
+ extra_body: dict[str, Any], key: str, value: Any, ignore_value: Any = None
+) -> None:
+ if key in extra_body:
+ return
+ if value is None:
+ return
+ if ignore_value is not None and value == ignore_value:
+ return
+ extra_body[key] = value
+
+
+def build_request_body(request_data: Any, nim: NimSettings) -> dict:
+ """Build OpenAI-format request body from Anthropic request."""
+ logger.debug(
+ "NIM_REQUEST: conversion start model={} msgs={}",
+ getattr(request_data, "model", "?"),
+ len(getattr(request_data, "messages", [])),
+ )
+ body = build_base_request_body(request_data)
+
+ # NIM-specific max_tokens: cap against nim.max_tokens
+ max_tokens = body.get("max_tokens") or getattr(request_data, "max_tokens", None)
+ if max_tokens is None:
+ max_tokens = nim.max_tokens
+ elif nim.max_tokens:
+ max_tokens = min(max_tokens, nim.max_tokens)
+ set_if_not_none(body, "max_tokens", max_tokens)
+
+ # NIM-specific temperature/top_p: fall back to NIM defaults if request didn't set
+ if body.get("temperature") is None and nim.temperature is not None:
+ body["temperature"] = nim.temperature
+ if body.get("top_p") is None and nim.top_p is not None:
+ body["top_p"] = nim.top_p
+
+ # NIM-specific stop sequences fallback
+ if "stop" not in body and nim.stop:
+ body["stop"] = nim.stop
+
+ if nim.presence_penalty != 0.0:
+ body["presence_penalty"] = nim.presence_penalty
+ if nim.frequency_penalty != 0.0:
+ body["frequency_penalty"] = nim.frequency_penalty
+ if nim.seed is not None:
+ body["seed"] = nim.seed
+
+ body["parallel_tool_calls"] = nim.parallel_tool_calls
+
+ # Handle non-standard parameters via extra_body
+ extra_body: dict[str, Any] = {}
+ request_extra = getattr(request_data, "extra_body", None)
+ if request_extra:
+ extra_body.update(request_extra)
+
+ if nim.enable_thinking:
+ extra_body.setdefault(
+ "chat_template_kwargs", {"thinking": True, "enable_thinking": True}
+ )
+ _set_extra(extra_body, "reasoning_budget", max_tokens)
+
+ req_top_k = getattr(request_data, "top_k", None)
+ top_k = req_top_k if req_top_k is not None else nim.top_k
+ _set_extra(extra_body, "top_k", top_k, ignore_value=-1)
+ _set_extra(extra_body, "min_p", nim.min_p, ignore_value=0.0)
+ _set_extra(
+ extra_body, "repetition_penalty", nim.repetition_penalty, ignore_value=1.0
+ )
+ _set_extra(extra_body, "min_tokens", nim.min_tokens, ignore_value=0)
+ _set_extra(extra_body, "chat_template", nim.chat_template)
+ _set_extra(extra_body, "request_id", nim.request_id)
+ _set_extra(extra_body, "ignore_eos", nim.ignore_eos)
+
+ if extra_body:
+ body["extra_body"] = extra_body
+
+ logger.debug(
+ "NIM_REQUEST: conversion done model={} msgs={} tools={}",
+ body.get("model"),
+ len(body.get("messages", [])),
+ len(body.get("tools", [])),
+ )
+ return body
diff --git a/Claude_Code/providers/open_router/__init__.py b/Claude_Code/providers/open_router/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a72244a960452c88fecd5f6805e7b2c5b9081985
--- /dev/null
+++ b/Claude_Code/providers/open_router/__init__.py
@@ -0,0 +1,5 @@
+"""OpenRouter provider - OpenAI-compatible API for hundreds of models."""
+
+from .client import OPENROUTER_BASE_URL, OpenRouterProvider
+
+__all__ = ["OPENROUTER_BASE_URL", "OpenRouterProvider"]
diff --git a/Claude_Code/providers/open_router/client.py b/Claude_Code/providers/open_router/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..a55d18a668af938c84408c789e021e326d9860af
--- /dev/null
+++ b/Claude_Code/providers/open_router/client.py
@@ -0,0 +1,38 @@
+"""OpenRouter provider implementation."""
+
+from collections.abc import Iterator
+from typing import Any
+
+from providers.base import ProviderConfig
+from providers.common import SSEBuilder
+from providers.openai_compat import OpenAICompatibleProvider
+
+from .request import build_request_body
+
+OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
+
+
+class OpenRouterProvider(OpenAICompatibleProvider):
+ """OpenRouter provider using OpenAI-compatible API."""
+
+ def __init__(self, config: ProviderConfig):
+ super().__init__(
+ config,
+ provider_name="OPENROUTER",
+ base_url=config.base_url or OPENROUTER_BASE_URL,
+ api_key=config.api_key,
+ )
+
+ def _build_request_body(self, request: Any) -> dict:
+ """Internal helper for tests and shared building."""
+ return build_request_body(request)
+
+ def _handle_extra_reasoning(self, delta: Any, sse: SSEBuilder) -> Iterator[str]:
+ """Handle reasoning_details for StepFun models."""
+ reasoning_details = getattr(delta, "reasoning_details", None)
+ if reasoning_details and isinstance(reasoning_details, list):
+ for item in reasoning_details:
+ text = item.get("text", "") if isinstance(item, dict) else ""
+ if text:
+ yield from sse.ensure_thinking_block()
+ yield sse.emit_thinking_delta(text)
diff --git a/Claude_Code/providers/open_router/request.py b/Claude_Code/providers/open_router/request.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbd2ad0e46e39fa550477c35522d7dddb37f9e80
--- /dev/null
+++ b/Claude_Code/providers/open_router/request.py
@@ -0,0 +1,47 @@
+"""Request builder for OpenRouter provider."""
+
+from typing import Any
+
+from loguru import logger
+
+from providers.common.message_converter import build_base_request_body
+
+OPENROUTER_DEFAULT_MAX_TOKENS = 81920
+
+
+def build_request_body(request_data: Any) -> dict:
+ """Build OpenAI-format request body from Anthropic request for OpenRouter."""
+ logger.debug(
+ "OPENROUTER_REQUEST: conversion start model={} msgs={}",
+ getattr(request_data, "model", "?"),
+ len(getattr(request_data, "messages", [])),
+ )
+ body = build_base_request_body(
+ request_data,
+ default_max_tokens=OPENROUTER_DEFAULT_MAX_TOKENS,
+ include_reasoning_for_openrouter=True,
+ )
+
+ # OpenRouter reasoning: extra_body={"reasoning": {"enabled": True}}
+ extra_body: dict[str, Any] = {}
+ request_extra = getattr(request_data, "extra_body", None)
+ if request_extra:
+ extra_body.update(request_extra)
+
+ thinking = getattr(request_data, "thinking", None)
+ thinking_enabled = (
+ thinking.enabled if thinking and hasattr(thinking, "enabled") else True
+ )
+ if thinking_enabled:
+ extra_body.setdefault("reasoning", {"enabled": True})
+
+ if extra_body:
+ body["extra_body"] = extra_body
+
+ logger.debug(
+ "OPENROUTER_REQUEST: conversion done model={} msgs={} tools={}",
+ body.get("model"),
+ len(body.get("messages", [])),
+ len(body.get("tools", [])),
+ )
+ return body
diff --git a/Claude_Code/providers/openai_compat.py b/Claude_Code/providers/openai_compat.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcc6ba903fa43650911c236464a2ed88a6f299ed
--- /dev/null
+++ b/Claude_Code/providers/openai_compat.py
@@ -0,0 +1,332 @@
+"""Shared base class for OpenAI-compatible providers (NIM, OpenRouter, LM Studio)."""
+
+import json
+import uuid
+from abc import abstractmethod
+from collections.abc import AsyncIterator, Iterator
+from typing import Any
+
+import httpx
+from loguru import logger
+from openai import AsyncOpenAI
+
+from providers.base import BaseProvider, ProviderConfig
+from providers.common import (
+ ContentType,
+ HeuristicToolParser,
+ SSEBuilder,
+ ThinkTagParser,
+ append_request_id,
+ get_user_facing_error_message,
+ map_error,
+ map_stop_reason,
+)
+from providers.rate_limit import GlobalRateLimiter
+
+
+class OpenAICompatibleProvider(BaseProvider):
+ """Base class for providers using OpenAI-compatible chat completions API."""
+
+ def __init__(
+ self,
+ config: ProviderConfig,
+ *,
+ provider_name: str,
+ base_url: str,
+ api_key: str,
+ ):
+ super().__init__(config)
+ self._provider_name = provider_name
+ self._api_key = api_key
+ self._base_url = base_url.rstrip("/")
+ self._global_rate_limiter = GlobalRateLimiter.get_instance(
+ rate_limit=config.rate_limit,
+ rate_window=config.rate_window,
+ max_concurrency=config.max_concurrency,
+ )
+ self._client = AsyncOpenAI(
+ api_key=self._api_key,
+ base_url=self._base_url,
+ max_retries=0,
+ timeout=httpx.Timeout(
+ config.http_read_timeout,
+ connect=config.http_connect_timeout,
+ read=config.http_read_timeout,
+ write=config.http_write_timeout,
+ ),
+ )
+
+ async def cleanup(self) -> None:
+ """Release HTTP client resources."""
+ client = getattr(self, "_client", None)
+ if client is not None:
+ await client.aclose()
+
+ @abstractmethod
+ def _build_request_body(self, request: Any) -> dict:
+ """Build request body. Must be implemented by subclasses."""
+
+ def _handle_extra_reasoning(self, delta: Any, sse: SSEBuilder) -> Iterator[str]:
+ """Hook for provider-specific reasoning (e.g. OpenRouter reasoning_details)."""
+ return iter(())
+
+ def _process_tool_call(self, tc: dict, sse: SSEBuilder) -> Iterator[str]:
+ """Process a single tool call delta and yield SSE events."""
+ tc_index = tc.get("index", 0)
+ if tc_index < 0:
+ tc_index = len(sse.blocks.tool_states)
+
+ fn_delta = tc.get("function", {})
+ incoming_name = fn_delta.get("name")
+ if incoming_name is not None:
+ sse.blocks.register_tool_name(tc_index, incoming_name)
+
+ state = sse.blocks.tool_states.get(tc_index)
+ if state is None or not state.started:
+ name = state.name if state else ""
+ if name or tc.get("id"):
+ tool_id = tc.get("id") or f"tool_{uuid.uuid4()}"
+ yield sse.start_tool_block(tc_index, tool_id, name)
+
+ args = fn_delta.get("arguments", "")
+ if args:
+ state = sse.blocks.tool_states.get(tc_index)
+ if state is None or not state.started:
+ tool_id = tc.get("id") or f"tool_{uuid.uuid4()}"
+ name = (state.name if state else None) or "tool_call"
+ yield sse.start_tool_block(tc_index, tool_id, name)
+ state = sse.blocks.tool_states.get(tc_index)
+
+ current_name = state.name if state else ""
+ if current_name == "Task":
+ parsed = sse.blocks.buffer_task_args(tc_index, args)
+ if parsed is not None:
+ yield sse.emit_tool_delta(tc_index, json.dumps(parsed))
+ return
+
+ yield sse.emit_tool_delta(tc_index, args)
+
+ def _flush_task_arg_buffers(self, sse: SSEBuilder) -> Iterator[str]:
+ """Emit buffered Task args as a single JSON delta (best-effort)."""
+ for tool_index, out in sse.blocks.flush_task_arg_buffers():
+ yield sse.emit_tool_delta(tool_index, out)
+
+ async def stream_response(
+ self,
+ request: Any,
+ input_tokens: int = 0,
+ *,
+ request_id: str | None = None,
+ ) -> AsyncIterator[str]:
+ """Stream response in Anthropic SSE format."""
+ with logger.contextualize(request_id=request_id):
+ async for event in self._stream_response_impl(
+ request, input_tokens, request_id
+ ):
+ yield event
+
+ async def _stream_response_impl(
+ self,
+ request: Any,
+ input_tokens: int,
+ request_id: str | None,
+ ) -> AsyncIterator[str]:
+ """Shared streaming implementation."""
+ tag = self._provider_name
+ message_id = f"msg_{uuid.uuid4()}"
+ sse = SSEBuilder(message_id, request.model, input_tokens)
+
+ body = self._build_request_body(request)
+ req_tag = f" request_id={request_id}" if request_id else ""
+ logger.info(
+ "{}_STREAM:{} model={} msgs={} tools={}",
+ tag,
+ req_tag,
+ body.get("model"),
+ len(body.get("messages", [])),
+ len(body.get("tools", [])),
+ )
+
+ yield sse.message_start()
+
+ think_parser = ThinkTagParser()
+ heuristic_parser = HeuristicToolParser()
+
+ finish_reason = None
+ usage_info = None
+ error_occurred = False
+ error_message = ""
+
+ async with self._global_rate_limiter.concurrency_slot():
+ try:
+ stream = await self._global_rate_limiter.execute_with_retry(
+ self._client.chat.completions.create, **body, stream=True
+ )
+ async for chunk in stream:
+ if getattr(chunk, "usage", None):
+ usage_info = chunk.usage
+
+ if not chunk.choices:
+ continue
+
+ choice = chunk.choices[0]
+ delta = choice.delta
+ if delta is None:
+ continue
+
+ if choice.finish_reason:
+ finish_reason = choice.finish_reason
+ logger.debug("{} finish_reason: {}", tag, finish_reason)
+
+ # Handle reasoning_content (OpenAI extended format)
+ reasoning = getattr(delta, "reasoning_content", None)
+ if reasoning:
+ for event in sse.ensure_thinking_block():
+ yield event
+ yield sse.emit_thinking_delta(reasoning)
+
+ # Provider-specific extra reasoning (e.g. OpenRouter reasoning_details)
+ for event in self._handle_extra_reasoning(delta, sse):
+ yield event
+
+ # Handle text content
+ if delta.content:
+ for part in think_parser.feed(delta.content):
+ if part.type == ContentType.THINKING:
+ for event in sse.ensure_thinking_block():
+ yield event
+ yield sse.emit_thinking_delta(part.content)
+ else:
+ filtered_text, detected_tools = heuristic_parser.feed(
+ part.content
+ )
+
+ if filtered_text:
+ for event in sse.ensure_text_block():
+ yield event
+ yield sse.emit_text_delta(filtered_text)
+
+ for tool_use in detected_tools:
+ for event in sse.close_content_blocks():
+ yield event
+
+ block_idx = sse.blocks.allocate_index()
+ if tool_use.get("name") == "Task" and isinstance(
+ tool_use.get("input"), dict
+ ):
+ tool_use["input"]["run_in_background"] = False
+ yield sse.content_block_start(
+ block_idx,
+ "tool_use",
+ id=tool_use["id"],
+ name=tool_use["name"],
+ )
+ yield sse.content_block_delta(
+ block_idx,
+ "input_json_delta",
+ json.dumps(tool_use["input"]),
+ )
+ yield sse.content_block_stop(block_idx)
+
+ # Handle native tool calls
+ if delta.tool_calls:
+ for event in sse.close_content_blocks():
+ yield event
+ for tc in delta.tool_calls:
+ tc_info = {
+ "index": tc.index,
+ "id": tc.id,
+ "function": {
+ "name": tc.function.name,
+ "arguments": tc.function.arguments,
+ },
+ }
+ for event in self._process_tool_call(tc_info, sse):
+ yield event
+
+ except Exception as e:
+ logger.error("{}_ERROR:{} {}: {}", tag, req_tag, type(e).__name__, e)
+ mapped_e = map_error(e)
+ error_occurred = True
+ error_message = append_request_id(
+ get_user_facing_error_message(
+ mapped_e, read_timeout_s=self._config.http_read_timeout
+ ),
+ request_id,
+ )
+ logger.info(
+ "{}_STREAM: Emitting SSE error event for {}{}",
+ tag,
+ type(e).__name__,
+ req_tag,
+ )
+ for event in sse.close_content_blocks():
+ yield event
+ for event in sse.emit_error(error_message):
+ yield event
+
+ # Flush remaining content
+ remaining = think_parser.flush()
+ if remaining:
+ if remaining.type == ContentType.THINKING:
+ for event in sse.ensure_thinking_block():
+ yield event
+ yield sse.emit_thinking_delta(remaining.content)
+ else:
+ for event in sse.ensure_text_block():
+ yield event
+ yield sse.emit_text_delta(remaining.content)
+
+ for tool_use in heuristic_parser.flush():
+ for event in sse.close_content_blocks():
+ yield event
+
+ block_idx = sse.blocks.allocate_index()
+ yield sse.content_block_start(
+ block_idx,
+ "tool_use",
+ id=tool_use["id"],
+ name=tool_use["name"],
+ )
+ if tool_use.get("name") == "Task" and isinstance(
+ tool_use.get("input"), dict
+ ):
+ tool_use["input"]["run_in_background"] = False
+ yield sse.content_block_delta(
+ block_idx,
+ "input_json_delta",
+ json.dumps(tool_use["input"]),
+ )
+ yield sse.content_block_stop(block_idx)
+
+ if (
+ not error_occurred
+ and sse.blocks.text_index == -1
+ and not sse.blocks.tool_states
+ ):
+ for event in sse.ensure_text_block():
+ yield event
+ yield sse.emit_text_delta(" ")
+
+ for event in self._flush_task_arg_buffers(sse):
+ yield event
+
+ for event in sse.close_all_blocks():
+ yield event
+
+ output_tokens = (
+ usage_info.completion_tokens
+ if usage_info and hasattr(usage_info, "completion_tokens")
+ else sse.estimate_output_tokens()
+ )
+ if usage_info and hasattr(usage_info, "prompt_tokens"):
+ provider_input = usage_info.prompt_tokens
+ if isinstance(provider_input, int):
+ logger.debug(
+ "TOKEN_ESTIMATE: our={} provider={} diff={:+d}",
+ input_tokens,
+ provider_input,
+ provider_input - input_tokens,
+ )
+ yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
+ yield sse.message_stop()
diff --git a/Claude_Code/providers/rate_limit.py b/Claude_Code/providers/rate_limit.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe2535a21e2389755f5e5f8855910f46cc0af064
--- /dev/null
+++ b/Claude_Code/providers/rate_limit.py
@@ -0,0 +1,230 @@
+"""Global rate limiter for API requests."""
+
+import asyncio
+import random
+import time
+from collections import deque
+from collections.abc import AsyncIterator, Callable
+from contextlib import asynccontextmanager
+from typing import Any, ClassVar, TypeVar
+
+import openai
+from loguru import logger
+
+T = TypeVar("T")
+
+
+class GlobalRateLimiter:
+ """
+ Global singleton rate limiter that blocks all requests
+ when a rate limit error is encountered (reactive) and
+ throttles requests (proactive) using a strict rolling window.
+
+ Optionally enforces a max_concurrency cap: at most N provider streams
+ may be open simultaneously, independent of the sliding window.
+
+ Proactive limits - throttles requests to stay within API limits.
+ Reactive limits - pauses all requests when a 429 is hit.
+ Concurrency limit - caps simultaneously open streams.
+ """
+
+ _instance: ClassVar["GlobalRateLimiter | None"] = None
+
+ def __new__(cls, *args: Any, **kwargs: Any) -> "GlobalRateLimiter":
+ if cls._instance is not None:
+ return cls._instance
+ instance = super().__new__(cls)
+ return instance
+
+ def __init__(
+ self,
+ rate_limit: int = 40,
+ rate_window: float = 60.0,
+ max_concurrency: int = 5,
+ ):
+ # Prevent re-initialization on singleton reuse
+ if hasattr(self, "_initialized"):
+ return
+
+ if rate_limit <= 0:
+ raise ValueError("rate_limit must be > 0")
+ if rate_window <= 0:
+ raise ValueError("rate_window must be > 0")
+ if max_concurrency <= 0:
+ raise ValueError("max_concurrency must be > 0")
+
+ self._rate_limit = rate_limit
+ self._rate_window = float(rate_window)
+ # Monotonic timestamps of the last granted slots.
+ self._request_times: deque[float] = deque()
+ self._blocked_until: float = 0
+ self._lock = asyncio.Lock()
+ self._concurrency_sem = asyncio.Semaphore(max_concurrency)
+ self._initialized = True
+
+ logger.info(
+ f"GlobalRateLimiter (Provider) initialized ({rate_limit} req / {rate_window}s, max_concurrency={max_concurrency})"
+ )
+
+ @classmethod
+ def get_instance(
+ cls,
+ rate_limit: int | None = None,
+ rate_window: float | None = None,
+ max_concurrency: int = 5,
+ ) -> "GlobalRateLimiter":
+ """Get or create the singleton instance.
+
+ Args:
+ rate_limit: Requests per window (only used on first creation)
+ rate_window: Window in seconds (only used on first creation)
+ max_concurrency: Max simultaneous open streams (only used on first creation)
+ """
+ if cls._instance is None:
+ cls._instance = cls(
+ rate_limit=rate_limit or 40,
+ rate_window=rate_window or 60.0,
+ max_concurrency=max_concurrency,
+ )
+ return cls._instance
+
+ @classmethod
+ def reset_instance(cls) -> None:
+ """Reset singleton (for testing)."""
+ cls._instance = None
+
+ async def wait_if_blocked(self) -> bool:
+ """
+ Wait if currently rate limited or throttle to meet quota.
+
+ Returns:
+ True if was reactively blocked and waited, False otherwise.
+ """
+ # 1. Reactive check: Wait if someone hit a 429
+ waited_reactively = False
+ now = time.monotonic()
+ if now < self._blocked_until:
+ wait_time = self._blocked_until - now
+ logger.warning(
+ f"Global provider rate limit active (reactive), waiting {wait_time:.1f}s..."
+ )
+ await asyncio.sleep(wait_time)
+ waited_reactively = True
+
+ # 2. Proactive check: strict rolling window (no bursts beyond N in last W seconds)
+ await self._acquire_proactive_slot()
+ return waited_reactively
+
+ async def _acquire_proactive_slot(self) -> None:
+ """
+ Acquire a proactive slot enforcing a strict rolling window.
+
+ Guarantees: at most `self._rate_limit` acquisitions in any interval of length
+ `self._rate_window` (seconds).
+ """
+ while True:
+ wait_time = 0.0
+ async with self._lock:
+ now = time.monotonic()
+ cutoff = now - self._rate_window
+
+ while self._request_times and self._request_times[0] <= cutoff:
+ self._request_times.popleft()
+
+ if len(self._request_times) < self._rate_limit:
+ self._request_times.append(now)
+ return
+
+ oldest = self._request_times[0]
+ wait_time = max(0.0, (oldest + self._rate_window) - now)
+
+ # Sleep outside the lock so other tasks can continue to queue.
+ if wait_time > 0:
+ await asyncio.sleep(wait_time)
+ else:
+ await asyncio.sleep(0)
+
+ def set_blocked(self, seconds: float = 60) -> None:
+ """
+ Set global block for specified seconds (reactive).
+
+ Args:
+ seconds: How long to block (default 60s)
+ """
+ self._blocked_until = time.monotonic() + seconds
+ logger.warning(f"Global provider rate limit set for {seconds:.1f}s (reactive)")
+
+ def is_blocked(self) -> bool:
+ """Check if currently reactively blocked."""
+ return time.monotonic() < self._blocked_until
+
+ def remaining_wait(self) -> float:
+ """Get remaining reactive wait time in seconds."""
+ return max(0.0, self._blocked_until - time.monotonic())
+
+ @asynccontextmanager
+ async def concurrency_slot(self) -> AsyncIterator[None]:
+ """Async context manager that holds one concurrency slot for a stream.
+
+ Blocks until a slot is available (controlled by max_concurrency).
+ """
+ await self._concurrency_sem.acquire()
+ try:
+ yield
+ finally:
+ self._concurrency_sem.release()
+
+ async def execute_with_retry(
+ self,
+ fn: Callable[..., Any],
+ *args: Any,
+ max_retries: int = 3,
+ base_delay: float = 2.0,
+ max_delay: float = 60.0,
+ jitter: float = 1.0,
+ **kwargs: Any,
+ ) -> Any:
+ """Execute an async callable with rate limiting and retry on 429.
+
+ Waits for the proactive limiter before each attempt. On 429, applies
+ exponential backoff with jitter before retrying.
+
+ Args:
+ fn: Async callable to execute.
+ max_retries: Maximum number of retry attempts after the first failure.
+ base_delay: Base delay in seconds for exponential backoff.
+ max_delay: Maximum delay cap in seconds.
+ jitter: Maximum random jitter in seconds added to each delay.
+
+ Returns:
+ The result of the callable.
+
+ Raises:
+ The last exception if all retries are exhausted.
+ """
+ last_exc: Exception | None = None
+
+ for attempt in range(1 + max_retries):
+ await self.wait_if_blocked()
+
+ try:
+ return await fn(*args, **kwargs)
+ except openai.RateLimitError as e:
+ last_exc = e
+ if attempt >= max_retries:
+ logger.warning(
+ f"Rate limit retry exhausted after {max_retries} retries"
+ )
+ break
+
+ delay = min(base_delay * (2**attempt), max_delay)
+ delay += random.uniform(0, jitter)
+ logger.warning(
+ f"Rate limited (429), attempt {attempt + 1}/{max_retries + 1}. "
+ f"Retrying in {delay:.1f}s..."
+ )
+ self.set_blocked(delay)
+ await asyncio.sleep(delay)
+
+ assert last_exc is not None
+ raise last_exc
diff --git a/Claude_Code/pyproject.toml b/Claude_Code/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..f3ee54f27270214223fbfff1f94acf0d6c031c28
--- /dev/null
+++ b/Claude_Code/pyproject.toml
@@ -0,0 +1,106 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "free-claude-code"
+version = "2.0.0"
+description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi[standard]>=0.115.11",
+ "uvicorn>=0.34.0",
+ "httpx>=0.25.0",
+ "markdown-it-py>=3.0.0",
+ "pydantic>=2.0.0",
+ "python-dotenv>=1.0.0",
+ "tiktoken>=0.7.0",
+ "python-telegram-bot>=21.0",
+ "discord.py>=2.0.0",
+ "pydantic-settings>=2.12.0",
+ "openai>=2.16.0",
+ "loguru>=0.7.0",
+]
+
+[project.scripts]
+free-claude-code = "cli.entrypoints:serve"
+fcc-init = "cli.entrypoints:init"
+
+[project.optional-dependencies]
+voice = [
+ "grpcio>=1.78.0",
+ "grpcio-tools>=1.78.0",
+ "nvidia-riva-client>=2.15.0",
+]
+voice_local = [
+ "torch>=2.0.0",
+ "transformers>=4.45.0",
+ "accelerate>=0.30.0",
+ "librosa>=0.10.0",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["api", "cli", "config", "messaging", "providers"]
+
+[tool.uv.sources]
+torch = { index = "pytorch-cu130" }
+
+[[tool.uv.index]]
+name = "pytorch-cu130"
+url = "https://download.pytorch.org/whl/cu130"
+explicit = true
+
+[dependency-groups]
+dev = [
+ "pytest>=9.0.2",
+ "pytest-asyncio>=1.3.0",
+ "pytest-cov>=7.0.0",
+ "ty>=0.0.1",
+ "ruff>=0.9.0",
+ "pytest-xdist>=3.8.0",
+]
+
+[tool.ruff]
+target-version = "py314"
+line-length = 88
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # Pyflakes (undefined names, unused imports)
+ "I", # isort (import ordering)
+ "UP", # pyupgrade (modernise syntax for target Python version)
+ "B", # flake8-bugbear (common bugs and anti-patterns)
+ "C4", # flake8-comprehensions (idiomatic comprehensions)
+ "SIM", # flake8-simplify (simplifiable code patterns)
+ "PERF", # Perflint (performance anti-patterns)
+ "RUF", # Ruff-specific rules
+]
+ignore = [
+ "E501", # line too long — enforced by the formatter instead
+ "B008", # FastAPI Depends() in argument defaults is intentional
+ "RUF006", # fire-and-forget tasks intentionally not awaited
+]
+
+[tool.ruff.lint.isort]
+known-first-party = ["api", "cli", "config", "messaging", "providers", "utils"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+line-ending = "auto"
+skip-magic-trailing-comma = false
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+addopts = "-n auto"
+
+[tool.ty.environment]
+python-version = "3.14"
+
+[tool.ty.analysis]
+# Optional voice_local extra: torch, transformers, librosa for local whisper transcription
+# Optional voice extra: nvidia-riva-client for nvidia_nim transcription provider
+allowed-unresolved-imports = ["torch", "transformers", "librosa", "riva.client"]
diff --git a/Claude_Code/requirements.txt b/Claude_Code/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..8a37779f14756236a5f96791440a9f3cf2a9fc28
--- /dev/null
+++ b/Claude_Code/requirements.txt
@@ -0,0 +1,10 @@
+fastapi[standard]>=0.115.11
+uvicorn>=0.34.0
+httpx>=0.25.0
+markdown-it-py>=3.0.0
+pydantic>=2.0.0
+python-dotenv>=1.0.0
+tiktoken>=0.7.0
+pydantic-settings>=2.12.0
+openai>=2.16.0
+loguru>=0.7.0
\ No newline at end of file
diff --git a/Claude_Code/server.py b/Claude_Code/server.py
new file mode 100644
index 0000000000000000000000000000000000000000..88422f35ee9741402c84d8e784f00749acfae534
--- /dev/null
+++ b/Claude_Code/server.py
@@ -0,0 +1,28 @@
+"""Claude Code Proxy - Entry Point
+
+Minimal entry point that imports the app from the api module.
+Run with: uv run uvicorn server:app --host 0.0.0.0 --port 8082 --timeout-graceful-shutdown 5
+"""
+
+from api.app import app, create_app
+
+__all__ = ["app", "create_app"]
+
+if __name__ == "__main__":
+ import uvicorn
+
+ from cli.process_registry import kill_all_best_effort
+ from config.settings import get_settings
+
+ settings = get_settings()
+ try:
+ uvicorn.run(
+ app,
+ host=settings.host,
+ port=settings.port,
+ log_level="debug",
+ timeout_graceful_shutdown=5,
+ )
+ finally:
+ # Safety net: cleanup subprocesses if lifespan shutdown doesn't fully run.
+ kill_all_best_effort()
diff --git a/Claude_Code/tests/api/test_api.py b/Claude_Code/tests/api/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b3305a66a0e963bcf9ee7577a4b62fab8a1fd97
--- /dev/null
+++ b/Claude_Code/tests/api/test_api.py
@@ -0,0 +1,207 @@
+from unittest.mock import MagicMock, patch
+
+from fastapi.testclient import TestClient
+
+from api.app import app
+from providers.nvidia_nim import NvidiaNimProvider
+
+# Mock provider
+mock_provider = MagicMock(spec=NvidiaNimProvider)
+
+# Track stream_response calls for test_model_mapping
+_stream_response_calls = []
+
+
+async def _mock_stream_response(*args, **kwargs):
+ """Minimal async generator for streaming tests."""
+ _stream_response_calls.append((args, kwargs))
+ yield "event: message_start\ndata: {}\n\n"
+ yield "[DONE]\n\n"
+
+
+mock_provider.stream_response = _mock_stream_response
+
+# Patch get_provider_for_type to always return mock_provider
+_patcher = patch("api.routes.get_provider_for_type", return_value=mock_provider)
+_patcher.start()
+
+client = TestClient(app)
+
+
+def test_root():
+ response = client.get("/")
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+
+def test_health():
+ response = client.get("/health")
+ assert response.status_code == 200
+ assert response.json()["status"] == "healthy"
+
+
+def test_create_message_stream():
+ """Create message returns streaming response."""
+ payload = {
+ "model": "claude-3-sonnet",
+ "messages": [{"role": "user", "content": "Hi"}],
+ "max_tokens": 100,
+ "stream": True,
+ }
+ response = client.post("/v1/messages", json=payload)
+ assert response.status_code == 200
+ assert "text/event-stream" in response.headers.get("content-type", "")
+ content = b"".join(response.iter_bytes())
+ assert b"message_start" in content or b"event:" in content
+
+
+def test_model_mapping():
+ # Test Haiku mapping
+ _stream_response_calls.clear()
+ payload_haiku = {
+ "model": "claude-3-haiku-20240307",
+ "messages": [{"role": "user", "content": "Hi"}],
+ "max_tokens": 100,
+ "stream": True,
+ }
+ client.post("/v1/messages", json=payload_haiku)
+ assert len(_stream_response_calls) == 1
+ args = _stream_response_calls[0][0]
+ assert args[0].model != "claude-3-haiku-20240307"
+ assert args[0].original_model == "claude-3-haiku-20240307"
+
+
+def test_error_fallbacks():
+ from providers.exceptions import (
+ AuthenticationError,
+ OverloadedError,
+ RateLimitError,
+ )
+
+ base_payload = {
+ "model": "test",
+ "messages": [{"role": "user", "content": "Hi"}],
+ "max_tokens": 10,
+ "stream": True,
+ }
+
+ def _raise_auth(*args, **kwargs):
+ raise AuthenticationError("Invalid Key")
+
+ def _raise_rate_limit(*args, **kwargs):
+ raise RateLimitError("Too Many Requests")
+
+ def _raise_overloaded(*args, **kwargs):
+ raise OverloadedError("Server Overloaded")
+
+ # 1. Authentication Error (401)
+ mock_provider.stream_response = _raise_auth
+ response = client.post("/v1/messages", json=base_payload)
+ assert response.status_code == 401
+ assert response.json()["error"]["type"] == "authentication_error"
+
+ # 2. Rate Limit (429)
+ mock_provider.stream_response = _raise_rate_limit
+ response = client.post("/v1/messages", json=base_payload)
+ assert response.status_code == 429
+ assert response.json()["error"]["type"] == "rate_limit_error"
+
+ # 3. Overloaded (529)
+ mock_provider.stream_response = _raise_overloaded
+ response = client.post("/v1/messages", json=base_payload)
+ assert response.status_code == 529
+ assert response.json()["error"]["type"] == "overloaded_error"
+
+ # Reset for subsequent tests
+ mock_provider.stream_response = _mock_stream_response
+
+
+def test_generic_exception_returns_500():
+ """Non-ProviderError exceptions are caught and returned as HTTPException(500)."""
+
+ def _raise_runtime(*args, **kwargs):
+ raise RuntimeError("unexpected crash")
+
+ mock_provider.stream_response = _raise_runtime
+ response = client.post(
+ "/v1/messages",
+ json={
+ "model": "test",
+ "messages": [{"role": "user", "content": "Hi"}],
+ "max_tokens": 10,
+ "stream": True,
+ },
+ )
+ assert response.status_code == 500
+ mock_provider.stream_response = _mock_stream_response
+
+
+def test_generic_exception_with_status_code():
+ """Generic exception with status_code attribute uses that status (getattr fallback)."""
+
+ class ExceptionWithStatus(RuntimeError):
+ def __init__(self, msg: str, status_code: int = 500):
+ super().__init__(msg)
+ self.status_code = status_code
+
+ def _raise_with_status(*args, **kwargs):
+ raise ExceptionWithStatus("bad gateway", 502)
+
+ mock_provider.stream_response = _raise_with_status
+ response = client.post(
+ "/v1/messages",
+ json={
+ "model": "test",
+ "messages": [{"role": "user", "content": "Hi"}],
+ "max_tokens": 10,
+ "stream": True,
+ },
+ )
+ assert response.status_code == 502
+ mock_provider.stream_response = _mock_stream_response
+
+
+def test_generic_exception_empty_message_returns_non_empty_detail():
+ """Exceptions with empty __str__ still return a readable HTTP detail."""
+
+ class SilentError(RuntimeError):
+ def __str__(self):
+ return ""
+
+ def _raise_silent(*args, **kwargs):
+ raise SilentError()
+
+ mock_provider.stream_response = _raise_silent
+ response = client.post(
+ "/v1/messages",
+ json={
+ "model": "test",
+ "messages": [{"role": "user", "content": "Hi"}],
+ "max_tokens": 10,
+ "stream": True,
+ },
+ )
+ assert response.status_code == 500
+ assert response.json()["detail"] != ""
+ mock_provider.stream_response = _mock_stream_response
+
+
+def test_count_tokens_endpoint():
+ """count_tokens endpoint returns token count."""
+ response = client.post(
+ "/v1/messages/count_tokens",
+ json={"model": "test", "messages": [{"role": "user", "content": "Hello"}]},
+ )
+ assert response.status_code == 200
+ assert "input_tokens" in response.json()
+
+
+def test_stop_endpoint_no_handler_no_cli_503():
+ """POST /stop without handler or cli_manager returns 503."""
+ # Ensure no handler or cli_manager on app state
+ if hasattr(app.state, "message_handler"):
+ delattr(app.state, "message_handler")
+ if hasattr(app.state, "cli_manager"):
+ delattr(app.state, "cli_manager")
+ response = client.post("/stop")
+ assert response.status_code == 503
diff --git a/Claude_Code/tests/api/test_app_lifespan_and_errors.py b/Claude_Code/tests/api/test_app_lifespan_and_errors.py
new file mode 100644
index 0000000000000000000000000000000000000000..0df0244efb81f2060931e68035767fbd0ab366da
--- /dev/null
+++ b/Claude_Code/tests/api/test_app_lifespan_and_errors.py
@@ -0,0 +1,349 @@
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+def test_create_app_provider_error_handler_returns_anthropic_format():
+ from api.app import create_app
+ from providers.exceptions import AuthenticationError
+
+ app = create_app()
+
+ @app.get("/raise_provider")
+ async def _raise_provider():
+ raise AuthenticationError("bad key")
+
+ api_app_mod = importlib.import_module("api.app")
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token=None,
+ allowed_telegram_user_id=None,
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir="",
+ claude_workspace="./agent_workspace",
+ host="127.0.0.1",
+ port=8082,
+ log_file="server.log",
+ )
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=AsyncMock()),
+ ):
+ with TestClient(app) as client:
+ resp = client.get("/raise_provider")
+ assert resp.status_code == 401
+ body = resp.json()
+ assert body["type"] == "error"
+ assert body["error"]["type"] == "authentication_error"
+
+
+def test_create_app_general_exception_handler_returns_500():
+ from api.app import create_app
+
+ app = create_app()
+
+ @app.get("/raise_general")
+ async def _raise_general():
+ raise RuntimeError("boom")
+
+ api_app_mod = importlib.import_module("api.app")
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token=None,
+ allowed_telegram_user_id=None,
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir="",
+ claude_workspace="./agent_workspace",
+ host="127.0.0.1",
+ port=8082,
+ log_file="server.log",
+ )
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=AsyncMock()),
+ ):
+ with TestClient(app, raise_server_exceptions=False) as client:
+ resp = client.get("/raise_general")
+ assert resp.status_code == 500
+ body = resp.json()
+ assert body["type"] == "error"
+ assert body["error"]["type"] == "api_error"
+
+
+@pytest.mark.parametrize(
+ "messaging_enabled", [True, False], ids=["with_platform", "no_platform"]
+)
+def test_app_lifespan_sets_state_and_cleans_up(tmp_path, messaging_enabled):
+ from api.app import create_app
+
+ app = create_app()
+
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token="token" if messaging_enabled else None,
+ allowed_telegram_user_id="123",
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir=str(tmp_path / "workspace"),
+ claude_workspace=str(tmp_path / "data"),
+ host="127.0.0.1",
+ port=8082,
+ log_file=str(tmp_path / "server.log"),
+ )
+
+ fake_platform = MagicMock()
+ fake_platform.name = "fake"
+ fake_platform.on_message = MagicMock()
+ fake_platform.start = AsyncMock()
+ fake_platform.stop = AsyncMock()
+
+ session_store = MagicMock()
+ session_store.get_all_trees.return_value = [{"t": 1}] if messaging_enabled else []
+ session_store.get_node_mapping.return_value = {"n": "t"}
+ session_store.sync_from_tree_data = MagicMock()
+
+ fake_queue = MagicMock()
+ fake_queue.cleanup_stale_nodes.return_value = 1
+ fake_queue.to_dict.return_value = {
+ "trees": [{"t": 1}],
+ "node_to_tree": {"n": "t"},
+ }
+
+ cli_manager = MagicMock()
+ cli_manager.stop_all = AsyncMock()
+
+ api_app_mod = importlib.import_module("api.app")
+
+ cleanup_provider = AsyncMock()
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider),
+ patch(
+ "messaging.platforms.factory.create_messaging_platform",
+ return_value=fake_platform if messaging_enabled else None,
+ ) as create_platform,
+ patch("messaging.session.SessionStore", return_value=session_store),
+ patch("cli.manager.CLISessionManager", return_value=cli_manager),
+ patch(
+ "messaging.trees.queue_manager.TreeQueueManager.from_dict",
+ return_value=fake_queue,
+ ),
+ TestClient(app),
+ ):
+ pass
+
+ if messaging_enabled:
+ create_platform.assert_called_once()
+ fake_platform.on_message.assert_called_once()
+ fake_platform.start.assert_awaited_once()
+ fake_platform.stop.assert_awaited_once()
+ cli_manager.stop_all.assert_awaited_once()
+ assert getattr(app.state, "message_handler", None) is not None
+ session_store.sync_from_tree_data.assert_called_once_with(
+ [{"t": 1}],
+ {"n": "t"},
+ )
+ else:
+ fake_platform.start.assert_not_awaited()
+ fake_platform.stop.assert_not_awaited()
+ cli_manager.stop_all.assert_not_awaited()
+ assert getattr(app.state, "messaging_platform", "missing") is None
+
+ cleanup_provider.assert_awaited_once()
+
+
+def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path):
+ from api.app import create_app
+
+ app = create_app()
+
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token="token",
+ allowed_telegram_user_id="123",
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir=str(tmp_path / "workspace"),
+ claude_workspace=str(tmp_path / "data"),
+ host="127.0.0.1",
+ port=8082,
+ log_file=str(tmp_path / "server.log"),
+ )
+
+ fake_platform = MagicMock()
+ fake_platform.name = "fake"
+ fake_platform.on_message = MagicMock()
+ fake_platform.start = AsyncMock()
+ fake_platform.stop = AsyncMock(side_effect=RuntimeError("stop failed"))
+
+ session_store = MagicMock()
+ session_store.get_all_trees.return_value = []
+ session_store.get_node_mapping.return_value = {}
+ session_store.sync_from_tree_data = MagicMock()
+
+ cli_manager = MagicMock()
+ cli_manager.stop_all = AsyncMock()
+
+ api_app_mod = importlib.import_module("api.app")
+ cleanup_provider = AsyncMock()
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider),
+ patch(
+ "messaging.platforms.factory.create_messaging_platform",
+ return_value=fake_platform,
+ ),
+ patch("messaging.session.SessionStore", return_value=session_store),
+ patch("cli.manager.CLISessionManager", return_value=cli_manager),
+ TestClient(app),
+ ):
+ pass
+
+ fake_platform.stop.assert_awaited_once()
+ cli_manager.stop_all.assert_awaited_once()
+ cleanup_provider.assert_awaited_once()
+
+
+def test_app_lifespan_messaging_import_error_no_crash(tmp_path, caplog):
+ """Messaging import failure logs warning and continues without crash."""
+ from api.app import create_app
+
+ app = create_app()
+
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token="token",
+ allowed_telegram_user_id="123",
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir=str(tmp_path / "workspace"),
+ claude_workspace=str(tmp_path / "data"),
+ host="127.0.0.1",
+ port=8082,
+ log_file=str(tmp_path / "server.log"),
+ )
+
+ api_app_mod = importlib.import_module("api.app")
+ cleanup_provider = AsyncMock()
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider),
+ patch(
+ "messaging.platforms.factory.create_messaging_platform",
+ side_effect=ImportError("discord not installed"),
+ ),
+ TestClient(app),
+ ):
+ pass
+
+ assert getattr(app.state, "messaging_platform", None) is None
+ cleanup_provider.assert_awaited_once()
+
+
+def test_app_lifespan_platform_start_exception_cleanup_still_runs(tmp_path):
+ """Exception during platform.start() logs error, cleanup still runs."""
+ from api.app import create_app
+
+ app = create_app()
+
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token="token",
+ allowed_telegram_user_id="123",
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir=str(tmp_path / "workspace"),
+ claude_workspace=str(tmp_path / "data"),
+ host="127.0.0.1",
+ port=8082,
+ log_file=str(tmp_path / "server.log"),
+ )
+
+ fake_platform = MagicMock()
+ fake_platform.name = "fake"
+ fake_platform.on_message = MagicMock()
+ fake_platform.start = AsyncMock(side_effect=RuntimeError("start failed"))
+ fake_platform.stop = AsyncMock()
+
+ session_store = MagicMock()
+ session_store.get_all_trees.return_value = []
+ session_store.get_node_mapping.return_value = {}
+ session_store.sync_from_tree_data = MagicMock()
+
+ cli_manager = MagicMock()
+ cli_manager.stop_all = AsyncMock()
+
+ api_app_mod = importlib.import_module("api.app")
+ cleanup_provider = AsyncMock()
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider),
+ patch(
+ "messaging.platforms.factory.create_messaging_platform",
+ return_value=fake_platform,
+ ),
+ patch("messaging.session.SessionStore", return_value=session_store),
+ patch("cli.manager.CLISessionManager", return_value=cli_manager),
+ TestClient(app),
+ ):
+ pass
+
+ cleanup_provider.assert_awaited_once()
+
+
+def test_app_lifespan_flush_pending_save_exception_warning_only(tmp_path):
+ """Session store flush exception on shutdown is logged as warning, no crash."""
+ from api.app import create_app
+
+ app = create_app()
+
+ settings = SimpleNamespace(
+ messaging_platform="telegram",
+ telegram_bot_token="token",
+ allowed_telegram_user_id="123",
+ discord_bot_token=None,
+ allowed_discord_channels=None,
+ allowed_dir=str(tmp_path / "workspace"),
+ claude_workspace=str(tmp_path / "data"),
+ host="127.0.0.1",
+ port=8082,
+ log_file=str(tmp_path / "server.log"),
+ )
+
+ fake_platform = MagicMock()
+ fake_platform.name = "fake"
+ fake_platform.on_message = MagicMock()
+ fake_platform.start = AsyncMock()
+ fake_platform.stop = AsyncMock()
+
+ session_store = MagicMock()
+ session_store.get_all_trees.return_value = []
+ session_store.get_node_mapping.return_value = {}
+ session_store.sync_from_tree_data = MagicMock()
+ session_store.flush_pending_save = MagicMock(side_effect=OSError("disk full"))
+
+ cli_manager = MagicMock()
+ cli_manager.stop_all = AsyncMock()
+
+ api_app_mod = importlib.import_module("api.app")
+ cleanup_provider = AsyncMock()
+ with (
+ patch.object(api_app_mod, "get_settings", return_value=settings),
+ patch.object(api_app_mod, "cleanup_provider", new=cleanup_provider),
+ patch(
+ "messaging.platforms.factory.create_messaging_platform",
+ return_value=fake_platform,
+ ),
+ patch("messaging.session.SessionStore", return_value=session_store),
+ patch("cli.manager.CLISessionManager", return_value=cli_manager),
+ TestClient(app),
+ ):
+ pass
+
+ session_store.flush_pending_save.assert_called_once()
+ cleanup_provider.assert_awaited_once()
diff --git a/Claude_Code/tests/api/test_auth.py b/Claude_Code/tests/api/test_auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..02e6ca36ea8e3fa5ce61a4840c264c057f684c05
--- /dev/null
+++ b/Claude_Code/tests/api/test_auth.py
@@ -0,0 +1,57 @@
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+
+from api.app import app
+from api.dependencies import get_settings
+from config.settings import Settings
+
+
+def test_anthropic_auth_token_required_and_accepts_x_api_key():
+ client = TestClient(app)
+ settings = Settings()
+ settings.anthropic_auth_token = "s3cr3t"
+ app.dependency_overrides[get_settings] = lambda: settings
+
+ payload = {
+ "model": "claude-3-sonnet",
+ "messages": [{"role": "user", "content": "hello"}],
+ }
+
+ with patch("api.routes.get_token_count", return_value=1):
+ # No header -> 401
+ r = client.post("/v1/messages/count_tokens", json=payload)
+ assert r.status_code == 401
+
+ # X-API-Key header -> 200
+ r = client.post(
+ "/v1/messages/count_tokens", json=payload, headers={"X-API-Key": "s3cr3t"}
+ )
+ assert r.status_code == 200
+ assert r.json()["input_tokens"] == 1
+
+ app.dependency_overrides.clear()
+
+
+def test_anthropic_auth_token_accepts_bearer_authorization():
+ client = TestClient(app)
+ settings = Settings()
+ settings.anthropic_auth_token = "b3artoken"
+ app.dependency_overrides[get_settings] = lambda: settings
+
+ payload = {
+ "model": "claude-3-sonnet",
+ "messages": [{"role": "user", "content": "hello"}],
+ }
+
+ with patch("api.routes.get_token_count", return_value=2):
+ # Authorization Bearer -> 200
+ r = client.post(
+ "/v1/messages/count_tokens",
+ json=payload,
+ headers={"Authorization": "Bearer b3artoken"},
+ )
+ assert r.status_code == 200
+ assert r.json()["input_tokens"] == 2
+
+ app.dependency_overrides.clear()
diff --git a/Claude_Code/tests/api/test_dependencies.py b/Claude_Code/tests/api/test_dependencies.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd30b95bd74dff2c4e08ba603bf614991d364e35
--- /dev/null
+++ b/Claude_Code/tests/api/test_dependencies.py
@@ -0,0 +1,290 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+
+from api.dependencies import (
+ cleanup_provider,
+ get_provider,
+ get_provider_for_type,
+ get_settings,
+)
+from config.nim import NimSettings
+from providers.lmstudio import LMStudioProvider
+from providers.nvidia_nim import NvidiaNimProvider
+from providers.open_router import OpenRouterProvider
+
+
+def _make_mock_settings(**overrides):
+ """Create a mock settings object with all required fields for get_provider()."""
+ mock = MagicMock()
+ mock.model = "nvidia_nim/meta/llama3"
+ mock.provider_type = "nvidia_nim"
+ mock.nvidia_nim_api_key = "test_key"
+ mock.provider_rate_limit = 40
+ mock.provider_rate_window = 60
+ mock.provider_max_concurrency = 5
+ mock.open_router_api_key = "test_openrouter_key"
+ mock.lm_studio_base_url = "http://localhost:1234/v1"
+ mock.nim = NimSettings()
+ mock.http_read_timeout = 300.0
+ mock.http_write_timeout = 10.0
+ mock.http_connect_timeout = 2.0
+ for key, value in overrides.items():
+ setattr(mock, key, value)
+ return mock
+
+
+@pytest.fixture(autouse=True)
+def reset_provider():
+ """Reset the global _providers registry between tests."""
+ import api.dependencies
+
+ saved = api.dependencies._providers
+ api.dependencies._providers = {}
+ yield
+ api.dependencies._providers = saved
+
+
+@pytest.mark.asyncio
+async def test_get_provider_singleton():
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ p1 = get_provider()
+ p2 = get_provider()
+
+ assert isinstance(p1, NvidiaNimProvider)
+ assert p1 is p2
+
+
+@pytest.mark.asyncio
+async def test_get_settings():
+ settings = get_settings()
+ assert settings is not None
+ # Verify it calls the internal _get_settings
+ with patch("api.dependencies._get_settings") as mock_get:
+ get_settings()
+ mock_get.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_cleanup_provider():
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ provider = get_provider()
+ assert isinstance(provider, NvidiaNimProvider)
+ provider._client = AsyncMock()
+
+ await cleanup_provider()
+
+ provider._client.aclose.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_cleanup_provider_no_client():
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ provider = get_provider()
+ if hasattr(provider, "_client"):
+ del provider._client
+
+ await cleanup_provider()
+ # Should not raise
+
+
+@pytest.mark.asyncio
+async def test_get_provider_open_router():
+ """Test that provider_type=open_router returns OpenRouterProvider."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(provider_type="open_router")
+
+ provider = get_provider()
+
+ assert isinstance(provider, OpenRouterProvider)
+ assert provider._base_url == "https://openrouter.ai/api/v1"
+ assert provider._api_key == "test_openrouter_key"
+
+
+@pytest.mark.asyncio
+async def test_get_provider_lmstudio():
+ """Test that provider_type=lmstudio returns LMStudioProvider."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(provider_type="lmstudio")
+
+ provider = get_provider()
+
+ assert isinstance(provider, LMStudioProvider)
+ assert provider._base_url == "http://localhost:1234/v1"
+
+
+@pytest.mark.asyncio
+async def test_get_provider_lmstudio_uses_lm_studio_base_url():
+ """LM Studio provider uses lm_studio_base_url from settings."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(
+ provider_type="lmstudio",
+ lm_studio_base_url="http://custom:9999/v1",
+ )
+
+ provider = get_provider()
+
+ assert isinstance(provider, LMStudioProvider)
+ assert provider._base_url == "http://custom:9999/v1"
+
+
+@pytest.mark.asyncio
+async def test_get_provider_passes_http_timeouts_from_settings():
+ """Provider receives http timeouts from settings when creating client."""
+ with (
+ patch("api.dependencies.get_settings") as mock_settings,
+ patch("providers.openai_compat.AsyncOpenAI") as mock_openai,
+ ):
+ mock_settings.return_value = _make_mock_settings(
+ http_read_timeout=600.0,
+ http_write_timeout=20.0,
+ http_connect_timeout=5.0,
+ )
+ provider = get_provider()
+ assert isinstance(provider, NvidiaNimProvider)
+ call_kwargs = mock_openai.call_args[1]
+ timeout = call_kwargs["timeout"]
+ assert timeout.read == 600.0
+ assert timeout.write == 20.0
+ assert timeout.connect == 5.0
+
+
+@pytest.mark.asyncio
+async def test_get_provider_nvidia_nim_missing_api_key():
+ """NVIDIA NIM with empty API key raises HTTPException 503."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(nvidia_nim_api_key="")
+
+ with pytest.raises(HTTPException) as exc_info:
+ get_provider()
+
+ assert exc_info.value.status_code == 503
+ assert "NVIDIA_NIM_API_KEY" in exc_info.value.detail
+ assert "build.nvidia.com" in exc_info.value.detail
+
+
+@pytest.mark.asyncio
+async def test_get_provider_nvidia_nim_whitespace_only_api_key():
+ """NVIDIA NIM with whitespace-only API key raises HTTPException 503."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(nvidia_nim_api_key=" ")
+
+ with pytest.raises(HTTPException) as exc_info:
+ get_provider()
+
+ assert exc_info.value.status_code == 503
+ assert "NVIDIA_NIM_API_KEY" in exc_info.value.detail
+
+
+@pytest.mark.asyncio
+async def test_get_provider_open_router_missing_api_key():
+ """OpenRouter with empty API key raises HTTPException 503."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(
+ provider_type="open_router",
+ open_router_api_key="",
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ get_provider()
+
+ assert exc_info.value.status_code == 503
+ assert "OPENROUTER_API_KEY" in exc_info.value.detail
+ assert "openrouter.ai" in exc_info.value.detail
+
+
+@pytest.mark.asyncio
+async def test_get_provider_unknown_type():
+ """Test that unknown provider_type raises ValueError."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(provider_type="unknown")
+
+ with pytest.raises(ValueError, match="Unknown provider_type"):
+ get_provider()
+
+
+@pytest.mark.asyncio
+async def test_cleanup_provider_aclose_raises():
+ """cleanup_provider handles aclose() raising an exception."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ provider = get_provider()
+ assert isinstance(provider, NvidiaNimProvider)
+ provider._client = AsyncMock()
+ provider._client.aclose = AsyncMock(side_effect=RuntimeError("cleanup failed"))
+
+ # Should propagate the error
+ with pytest.raises(RuntimeError, match="cleanup failed"):
+ await cleanup_provider()
+
+
+# --- Provider Registry Tests ---
+
+
+@pytest.mark.asyncio
+async def test_get_provider_for_type_caches():
+ """get_provider_for_type returns cached provider on second call."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ p1 = get_provider_for_type("nvidia_nim")
+ p2 = get_provider_for_type("nvidia_nim")
+
+ assert p1 is p2
+ assert isinstance(p1, NvidiaNimProvider)
+
+
+@pytest.mark.asyncio
+async def test_get_provider_for_type_different_types():
+ """get_provider_for_type creates separate providers per type."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ nim = get_provider_for_type("nvidia_nim")
+ lmstudio = get_provider_for_type("lmstudio")
+
+ assert isinstance(nim, NvidiaNimProvider)
+ assert isinstance(lmstudio, LMStudioProvider)
+ assert nim is not lmstudio
+
+
+@pytest.mark.asyncio
+async def test_get_provider_for_type_missing_key_raises_503():
+ """get_provider_for_type raises HTTPException 503 for missing API key."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings(open_router_api_key="")
+
+ with pytest.raises(HTTPException) as exc_info:
+ get_provider_for_type("open_router")
+
+ assert exc_info.value.status_code == 503
+ assert "OPENROUTER_API_KEY" in exc_info.value.detail
+
+
+@pytest.mark.asyncio
+async def test_cleanup_provider_cleans_all():
+ """cleanup_provider cleans up all providers in the registry."""
+ with patch("api.dependencies.get_settings") as mock_settings:
+ mock_settings.return_value = _make_mock_settings()
+
+ nim = get_provider_for_type("nvidia_nim")
+ lmstudio = get_provider_for_type("lmstudio")
+
+ assert isinstance(nim, NvidiaNimProvider)
+ assert isinstance(lmstudio, LMStudioProvider)
+
+ nim._client = AsyncMock()
+ lmstudio._client = AsyncMock()
+
+ await cleanup_provider()
+
+ nim._client.aclose.assert_called_once()
+ lmstudio._client.aclose.assert_called_once()
diff --git a/Claude_Code/tests/api/test_detection.py b/Claude_Code/tests/api/test_detection.py
new file mode 100644
index 0000000000000000000000000000000000000000..a28564a05b074fec4580b6f7d069866ac782a610
--- /dev/null
+++ b/Claude_Code/tests/api/test_detection.py
@@ -0,0 +1,80 @@
+"""Edge case tests for api/detection.py."""
+
+from unittest.mock import patch
+
+from api.detection import (
+ is_filepath_extraction_request,
+ is_prefix_detection_request,
+)
+from api.models.anthropic import Message, MessagesRequest
+
+
+def _make_request(content: str, **kwargs) -> MessagesRequest:
+ return MessagesRequest(
+ model="claude-3-sonnet",
+ max_tokens=100,
+ messages=[Message(role="user", content=content)],
+ **kwargs,
+ )
+
+
+class TestIsPrefixDetectionRequest:
+ def test_output_marker_handling(self):
+ """Content with Command: but Output: after cmd_start; output has < or \\n\\n."""
+ content = " Command:\nls -la\nOutput:\na.txt\nb.txt\n\nmore"
+ req = _make_request(content)
+ is_req, cmd = is_prefix_detection_request(req)
+ assert is_req is True
+ assert "ls -la" in cmd
+
+ def test_prefix_detection_with_empty_command_section(self):
+ """Command: at end with no content returns empty command."""
+ req = _make_request(" Command: ")
+ is_req, cmd = is_prefix_detection_request(req)
+ assert is_req is True
+ assert cmd == ""
+
+ def test_exception_in_try_returns_false(self):
+ """Exception in try block (e.g. content slice) returns False, ''."""
+ req = _make_request(" Command: x")
+
+ # Return object that raises when sliced - triggers except in is_prefix_detection_request
+ class BadStr(str):
+ def __getitem__(self, key):
+ raise TypeError("bad slice")
+
+ with patch(
+ "api.detection.extract_text_from_content",
+ return_value=BadStr(" Command: x"),
+ ):
+ is_req, cmd = is_prefix_detection_request(req)
+ assert is_req is False
+ assert cmd == ""
+
+
+class TestIsFilepathExtractionRequest:
+ def test_output_marker_minus_one_returns_false(self):
+ """Output: not found after Command: returns False."""
+ content = "Command:\nls\nfilepaths"
+ req = _make_request(content)
+ is_fp, cmd, out = is_filepath_extraction_request(req)
+ assert is_fp is False
+ assert cmd == ""
+ assert out == ""
+
+ def test_output_has_angle_bracket_splits(self):
+ """Output containing < is split and first part used."""
+ content = "Command:\nls\nOutput:\na.txt b.txt \nfilepaths"
+ req = _make_request(content)
+ is_fp, _cmd, out = is_filepath_extraction_request(req)
+ assert is_fp is True
+ assert "<" not in out
+ assert out == "a.txt b.txt"
+
+ def test_output_has_double_newline_splits(self):
+ """Output containing \\n\\n is split and first part used."""
+ content = "Command:\nls\nOutput:\na.txt\nb.txt\n\nmore text\nfilepaths"
+ req = _make_request(content)
+ is_fp, _cmd, out = is_filepath_extraction_request(req)
+ assert is_fp is True
+ assert "more" not in out
diff --git a/Claude_Code/tests/api/test_models_validators.py b/Claude_Code/tests/api/test_models_validators.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6089dea1ceef2398fdf2a13a54125cd595dcae2
--- /dev/null
+++ b/Claude_Code/tests/api/test_models_validators.py
@@ -0,0 +1,163 @@
+from unittest.mock import patch
+
+import pytest
+
+from api.models.anthropic import Message, MessagesRequest, TokenCountRequest
+from config.settings import Settings
+
+
+@pytest.fixture
+def mock_settings():
+ settings = Settings()
+ settings.model = "nvidia_nim/target-model-from-settings"
+ settings.model_opus = None
+ settings.model_sonnet = None
+ settings.model_haiku = None
+ return settings
+
+
+def test_messages_request_map_model_claude_to_default(mock_settings):
+ with patch("api.models.anthropic.get_settings", return_value=mock_settings):
+ request = MessagesRequest(
+ model="claude-3-opus",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+
+ assert request.model == "target-model-from-settings"
+ assert request.original_model == "claude-3-opus"
+
+
+def test_messages_request_map_model_with_provider_prefix(mock_settings):
+ with patch("api.models.anthropic.get_settings", return_value=mock_settings):
+ request = MessagesRequest(
+ model="anthropic/claude-3-haiku",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+
+ assert request.model == "target-model-from-settings"
+
+
+def test_token_count_request_model_validation(mock_settings):
+ with patch("api.models.anthropic.get_settings", return_value=mock_settings):
+ request = TokenCountRequest(
+ model="claude-3-sonnet", messages=[Message(role="user", content="hello")]
+ )
+
+ assert request.model == "target-model-from-settings"
+
+
+def test_messages_request_model_mapping_logs(mock_settings):
+ with (
+ patch("api.models.anthropic.get_settings", return_value=mock_settings),
+ patch("api.models.anthropic.logger.debug") as mock_log,
+ ):
+ MessagesRequest(
+ model="claude-2.1",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+
+ mock_log.assert_called()
+ args = mock_log.call_args[0][0]
+ assert "MODEL MAPPING" in args
+ assert "claude-2.1" in args
+ assert "target-model-from-settings" in args
+
+
+def test_messages_request_resolved_provider_model_default(mock_settings):
+ """resolved_provider_model is set to the full model string."""
+ with patch("api.models.anthropic.get_settings", return_value=mock_settings):
+ request = MessagesRequest(
+ model="claude-3-opus",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+ assert (
+ request.resolved_provider_model == "nvidia_nim/target-model-from-settings"
+ )
+
+
+def test_messages_request_model_aware_opus_override():
+ """Opus model routes to MODEL_OPUS when set."""
+ settings = Settings()
+ settings.model = "nvidia_nim/fallback-model"
+ settings.model_opus = "open_router/deepseek/deepseek-r1"
+
+ with patch("api.models.anthropic.get_settings", return_value=settings):
+ request = MessagesRequest(
+ model="claude-opus-4-20250514",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+ assert request.model == "deepseek/deepseek-r1"
+ assert request.resolved_provider_model == "open_router/deepseek/deepseek-r1"
+ assert request.original_model == "claude-opus-4-20250514"
+
+
+def test_messages_request_model_aware_haiku_override():
+ """Haiku model routes to MODEL_HAIKU when set."""
+ settings = Settings()
+ settings.model = "nvidia_nim/fallback-model"
+ settings.model_haiku = "lmstudio/qwen2.5-7b"
+
+ with patch("api.models.anthropic.get_settings", return_value=settings):
+ request = MessagesRequest(
+ model="claude-3-haiku-20240307",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+ assert request.model == "qwen2.5-7b"
+ assert request.resolved_provider_model == "lmstudio/qwen2.5-7b"
+
+
+def test_messages_request_model_aware_sonnet_override():
+ """Sonnet model routes to MODEL_SONNET when set."""
+ settings = Settings()
+ settings.model = "nvidia_nim/fallback-model"
+ settings.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct"
+
+ with patch("api.models.anthropic.get_settings", return_value=settings):
+ request = MessagesRequest(
+ model="claude-sonnet-4-20250514",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+ assert request.model == "meta/llama-3.3-70b-instruct"
+ assert (
+ request.resolved_provider_model == "nvidia_nim/meta/llama-3.3-70b-instruct"
+ )
+
+
+def test_messages_request_model_fallback_when_not_set():
+ """When model override is None, falls back to MODEL."""
+ settings = Settings()
+ settings.model = "nvidia_nim/fallback-model"
+ settings.model_opus = None
+ settings.model_sonnet = None
+ settings.model_haiku = None
+ # model_opus is None
+
+ with patch("api.models.anthropic.get_settings", return_value=settings):
+ request = MessagesRequest(
+ model="claude-opus-4-20250514",
+ max_tokens=100,
+ messages=[Message(role="user", content="hello")],
+ )
+ assert request.model == "fallback-model"
+ assert request.resolved_provider_model == "nvidia_nim/fallback-model"
+
+
+def test_token_count_request_model_aware():
+ """TokenCountRequest also uses model-aware resolution."""
+ settings = Settings()
+ settings.model = "nvidia_nim/fallback-model"
+ settings.model_haiku = "lmstudio/qwen2.5-7b"
+
+ with patch("api.models.anthropic.get_settings", return_value=settings):
+ request = TokenCountRequest(
+ model="claude-3-haiku-20240307",
+ messages=[Message(role="user", content="hello")],
+ )
+ assert request.model == "qwen2.5-7b"
diff --git a/Claude_Code/tests/api/test_optimization_handlers.py b/Claude_Code/tests/api/test_optimization_handlers.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb15d0168340a9a729687818adc12aa8daa0dafd
--- /dev/null
+++ b/Claude_Code/tests/api/test_optimization_handlers.py
@@ -0,0 +1,229 @@
+"""Tests for api/optimization_handlers.py."""
+
+from unittest.mock import patch
+
+from api.models.anthropic import ContentBlockText, Message, MessagesRequest
+from api.optimization_handlers import (
+ try_filepath_mock,
+ try_optimizations,
+ try_prefix_detection,
+ try_quota_mock,
+ try_suggestion_skip,
+ try_title_skip,
+)
+from config.settings import Settings
+
+
+def _make_request(
+ messages_content: str, max_tokens: int | None = None
+) -> MessagesRequest:
+ """Create a MessagesRequest with a single user message."""
+ return MessagesRequest(
+ model="claude-3-sonnet",
+ max_tokens=max_tokens if max_tokens is not None else 100,
+ messages=[Message(role="user", content=messages_content)],
+ )
+
+
+class TestTryPrefixDetection:
+ def test_disabled_returns_none(self):
+ settings = Settings()
+ settings.fast_prefix_detection = False
+ req = _make_request("x")
+ with patch(
+ "api.optimization_handlers.is_prefix_detection_request",
+ return_value=(True, "/ask"),
+ ):
+ assert try_prefix_detection(req, settings) is None
+
+ def test_enabled_and_match_returns_response(self):
+ settings = Settings()
+ settings.fast_prefix_detection = True
+ req = _make_request("x")
+ with (
+ patch(
+ "api.optimization_handlers.is_prefix_detection_request",
+ return_value=(True, "/ask"),
+ ),
+ patch(
+ "api.optimization_handlers.extract_command_prefix",
+ return_value="/ask",
+ ),
+ patch("api.optimization_handlers.logger.info") as mock_log_info,
+ ):
+ result = try_prefix_detection(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert block.text == "/ask"
+ mock_log_info.assert_called_once_with(
+ "Optimization: Fast prefix detection request"
+ )
+
+ def test_enabled_but_no_match_returns_none(self):
+ settings = Settings()
+ settings.fast_prefix_detection = True
+ req = _make_request("x")
+ with patch(
+ "api.optimization_handlers.is_prefix_detection_request",
+ return_value=(False, ""),
+ ):
+ assert try_prefix_detection(req, settings) is None
+
+
+class TestTryQuotaMock:
+ def test_disabled_returns_none(self):
+ settings = Settings()
+ settings.enable_network_probe_mock = False
+ req = _make_request("quota", max_tokens=1)
+ with patch(
+ "api.optimization_handlers.is_quota_check_request",
+ return_value=True,
+ ):
+ assert try_quota_mock(req, settings) is None
+
+ def test_enabled_and_match_returns_response(self):
+ settings = Settings()
+ settings.enable_network_probe_mock = True
+ req = _make_request("quota", max_tokens=1)
+ with patch(
+ "api.optimization_handlers.is_quota_check_request",
+ return_value=True,
+ ):
+ result = try_quota_mock(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert "Quota check passed" in block.text
+
+
+class TestTryTitleSkip:
+ def test_disabled_returns_none(self):
+ settings = Settings()
+ settings.enable_title_generation_skip = False
+ req = _make_request("write a 5-10 word title")
+ with patch(
+ "api.optimization_handlers.is_title_generation_request",
+ return_value=True,
+ ):
+ assert try_title_skip(req, settings) is None
+
+ def test_enabled_and_match_returns_response(self):
+ settings = Settings()
+ settings.enable_title_generation_skip = True
+ req = _make_request("x")
+ with patch(
+ "api.optimization_handlers.is_title_generation_request",
+ return_value=True,
+ ):
+ result = try_title_skip(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert block.text == "Conversation"
+
+
+class TestTrySuggestionSkip:
+ def test_disabled_returns_none(self):
+ settings = Settings()
+ settings.enable_suggestion_mode_skip = False
+ req = _make_request("[SUGGESTION MODE: x]")
+ with patch(
+ "api.optimization_handlers.is_suggestion_mode_request",
+ return_value=True,
+ ):
+ assert try_suggestion_skip(req, settings) is None
+
+ def test_enabled_and_match_returns_response(self):
+ settings = Settings()
+ settings.enable_suggestion_mode_skip = True
+ req = _make_request("x")
+ with patch(
+ "api.optimization_handlers.is_suggestion_mode_request",
+ return_value=True,
+ ):
+ result = try_suggestion_skip(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert block.text == ""
+
+
+class TestTryFilepathMock:
+ def test_disabled_returns_none(self):
+ settings = Settings()
+ settings.enable_filepath_extraction_mock = False
+ req = _make_request("Command:\nls\nOutput:\nfilepaths")
+ with patch(
+ "api.optimization_handlers.is_filepath_extraction_request",
+ return_value=(True, "ls", "out"),
+ ):
+ assert try_filepath_mock(req, settings) is None
+
+ def test_enabled_and_match_returns_response(self):
+ settings = Settings()
+ settings.enable_filepath_extraction_mock = True
+ req = _make_request("x")
+ with (
+ patch(
+ "api.optimization_handlers.is_filepath_extraction_request",
+ return_value=(True, "ls", "a.txt b.txt"),
+ ),
+ patch(
+ "api.optimization_handlers.extract_filepaths_from_command",
+ return_value="a.txt\nb.txt",
+ ),
+ ):
+ result = try_filepath_mock(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert block.text == "a.txt\nb.txt"
+
+ def test_extract_filepaths_empty_list_still_returns_response(self):
+ settings = Settings()
+ settings.enable_filepath_extraction_mock = True
+ req = _make_request("x")
+ with (
+ patch(
+ "api.optimization_handlers.is_filepath_extraction_request",
+ return_value=(True, "ls", "out"),
+ ),
+ patch(
+ "api.optimization_handlers.extract_filepaths_from_command",
+ return_value="",
+ ),
+ ):
+ result = try_filepath_mock(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert block.text == ""
+
+
+class TestTryOptimizations:
+ def test_first_match_wins(self):
+ """Quota mock is first in OPTIMIZATION_HANDLERS; it should win over prefix."""
+ settings = Settings()
+ settings.enable_network_probe_mock = True
+ settings.fast_prefix_detection = True
+ req = _make_request("quota", max_tokens=1)
+ with patch(
+ "api.optimization_handlers.is_quota_check_request",
+ return_value=True,
+ ):
+ result = try_optimizations(req, settings)
+ assert result is not None
+ block = result.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert "Quota check passed" in block.text
+
+ def test_no_match_returns_none(self):
+ settings = Settings()
+ settings.fast_prefix_detection = False
+ settings.enable_network_probe_mock = False
+ settings.enable_title_generation_skip = False
+ settings.enable_suggestion_mode_skip = False
+ settings.enable_filepath_extraction_mock = False
+ req = _make_request("random user message")
+ assert try_optimizations(req, settings) is None
diff --git a/Claude_Code/tests/api/test_request_utils.py b/Claude_Code/tests/api/test_request_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..635705c0e8eb60a0c38cdb3fdc074bc5ee6122ab
--- /dev/null
+++ b/Claude_Code/tests/api/test_request_utils.py
@@ -0,0 +1,613 @@
+"""Tests for api/request_utils.py module."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from api.command_utils import extract_command_prefix
+from api.detection import (
+ is_prefix_detection_request,
+ is_quota_check_request,
+ is_title_generation_request,
+)
+from api.models.anthropic import Message, MessagesRequest
+from api.request_utils import get_token_count
+
+
+class TestQuotaCheckRequest:
+ """Tests for is_quota_check_request function."""
+
+ def test_quota_check_simple_string(self):
+ """Test quota check with simple string content."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "Check my quota"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is True
+
+ def test_quota_check_case_insensitive(self):
+ """Test quota check is case insensitive."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "Check my QUOTA"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is True
+
+ def test_quota_check_list_content(self):
+ """Test quota check with list content blocks."""
+ block = MagicMock()
+ block.text = "Check my quota"
+
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = [block]
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is True
+
+ def test_not_quota_check_wrong_max_tokens(self):
+ """Test not quota check when max_tokens != 1."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "Check my quota"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 100
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is False
+
+ def test_not_quota_check_multiple_messages(self):
+ """Test not quota check when multiple messages."""
+ msg1 = MagicMock(spec=Message)
+ msg1.role = "user"
+ msg1.content = "Check my quota"
+
+ msg2 = MagicMock(spec=Message)
+ msg2.role = "assistant"
+ msg2.content = "Hello"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = [msg1, msg2]
+
+ assert is_quota_check_request(req) is False
+
+ def test_not_quota_check_wrong_role(self):
+ """Test not quota check when role is not user."""
+ msg = MagicMock(spec=Message)
+ msg.role = "assistant"
+ msg.content = "Check my quota"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is False
+
+ def test_not_quota_check_no_quota_keyword(self):
+ """Test not quota check when content doesn't contain quota."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "Hello world"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is False
+
+
+class TestTitleGenerationRequest:
+ """Tests for is_title_generation_request function."""
+
+ def _title_gen_system(self) -> list[MagicMock]:
+ block = MagicMock()
+ block.text = "Analyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title."
+ return [block]
+
+ def test_title_generation_detected_via_system(self):
+ """Title gen detected by system prompt containing topic/title keywords."""
+ req = MagicMock(spec=MessagesRequest)
+ req.system = self._title_gen_system()
+ req.tools = None
+
+ assert is_title_generation_request(req) is True
+
+ def test_title_generation_not_detected_with_tools(self):
+ """Not detected when tools are present (main conversation, not title gen)."""
+ req = MagicMock(spec=MessagesRequest)
+ req.system = self._title_gen_system()
+ req.tools = [MagicMock()]
+
+ assert is_title_generation_request(req) is False
+
+ def test_title_generation_not_detected_no_system(self):
+ """Not detected when system is absent."""
+ req = MagicMock(spec=MessagesRequest)
+ req.system = None
+ req.tools = None
+
+ assert is_title_generation_request(req) is False
+
+ def test_title_generation_not_detected_unrelated_system(self):
+ """Not detected when system prompt has no topic/title keywords."""
+ block = MagicMock()
+ block.text = "You are a helpful assistant."
+ req = MagicMock(spec=MessagesRequest)
+ req.system = [block]
+ req.tools = None
+
+ assert is_title_generation_request(req) is False
+
+
+class TestExtractCommandPrefix:
+ """Tests for extract_command_prefix function."""
+
+ def test_simple_command(self):
+ """Test extraction of simple command."""
+ assert extract_command_prefix("git status") == "git status"
+ assert extract_command_prefix("ls -la") == "ls"
+
+ def test_two_word_commands(self):
+ """Test extraction of two-word commands."""
+ assert extract_command_prefix("git commit -m 'message'") == "git commit"
+ assert extract_command_prefix("npm install package") == "npm install"
+ assert extract_command_prefix("docker run image") == "docker run"
+ assert extract_command_prefix("kubectl get pods") == "kubectl get"
+
+ def test_two_word_command_with_options(self):
+ """Test two-word command with options only returns first word."""
+ assert extract_command_prefix("git -v") == "git"
+ assert extract_command_prefix("npm --version") == "npm"
+
+ def test_with_env_vars(self):
+ """Test command with environment variables."""
+ assert extract_command_prefix("DEBUG=1 python script.py") == "DEBUG=1 python"
+ assert (
+ extract_command_prefix("API_KEY=secret node app.js")
+ == "API_KEY=secret node"
+ )
+
+ def test_single_word_commands(self):
+ """Test single word commands."""
+ assert extract_command_prefix("ls") == "ls"
+ assert extract_command_prefix("python") == "python"
+ assert extract_command_prefix("make") == "make"
+
+ def test_command_injection_detected(self):
+ """Test detection of command injection attempts."""
+ assert extract_command_prefix("`whoami`") == "command_injection_detected"
+ assert extract_command_prefix("$(whoami)") == "command_injection_detected"
+ assert (
+ extract_command_prefix("echo $(cat /etc/passwd)")
+ == "command_injection_detected"
+ )
+
+ def test_empty_command(self):
+ """Test handling of empty commands."""
+ assert extract_command_prefix("") == "none"
+ assert extract_command_prefix(" ") == "none"
+
+ def test_complex_git_command(self):
+ """Test complex git command extraction."""
+ assert extract_command_prefix("git log --oneline --graph") == "git log"
+ assert (
+ extract_command_prefix("git checkout -b feature-branch") == "git checkout"
+ )
+
+ def test_cargo_command(self):
+ """Test cargo command extraction."""
+ assert extract_command_prefix("cargo build") == "cargo build"
+ assert extract_command_prefix("cargo test") == "cargo test"
+ assert extract_command_prefix("cargo --version") == "cargo"
+
+
+class TestPrefixDetectionRequest:
+ """Tests for is_prefix_detection_request function."""
+
+ def test_prefix_detection_with_policy_spec(self):
+ """Test prefix detection with policy spec and command."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "policy Command: git status"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = [msg]
+
+ is_prefix, command = is_prefix_detection_request(req)
+ assert is_prefix is True
+ assert command == "git status"
+
+ def test_prefix_detection_case_sensitive(self):
+ """Test prefix detection is case sensitive for Command:."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "policy command: git status"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = [msg]
+
+ is_prefix, command = is_prefix_detection_request(req)
+ assert is_prefix is False
+ assert command == ""
+
+ def test_not_prefix_detection_no_policy_spec(self):
+ """Test not prefix detection without policy_spec."""
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = "Command: git status"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = [msg]
+
+ is_prefix, command = is_prefix_detection_request(req)
+ assert is_prefix is False
+ assert command == ""
+
+ def test_not_prefix_detection_multiple_messages(self):
+ """Test not prefix detection with multiple messages."""
+ msg1 = MagicMock(spec=Message)
+ msg1.role = "user"
+ msg1.content = "policy Command: git status"
+
+ msg2 = MagicMock(spec=Message)
+ msg2.role = "assistant"
+ msg2.content = "OK"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = [msg1, msg2]
+
+ is_prefix, command = is_prefix_detection_request(req)
+ assert is_prefix is False
+ assert command == ""
+
+ def test_not_prefix_detection_wrong_role(self):
+ """Test not prefix detection when message is not from user."""
+ msg = MagicMock(spec=Message)
+ msg.role = "assistant"
+ msg.content = "policy Command: git status"
+
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = [msg]
+
+ is_prefix, command = is_prefix_detection_request(req)
+ assert is_prefix is False
+ assert command == ""
+
+ def test_prefix_detection_list_content(self):
+ """Test prefix detection with list content blocks."""
+ block = MagicMock()
+ block.text = "policy Command: ls -la"
+
+ msg = MagicMock(spec=Message)
+ msg.role = "user"
+ msg.content = [block]
+
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = [msg]
+
+ is_prefix, command = is_prefix_detection_request(req)
+ assert is_prefix is True
+ assert command == "ls -la"
+
+
+class TestGetTokenCount:
+ """Tests for get_token_count function."""
+
+ def test_empty_messages(self):
+ """Test token count with empty messages."""
+ count = get_token_count([])
+ assert count >= 1 # Returns max(1, tokens)
+
+ def test_simple_message(self):
+ """Test token count with simple text message."""
+ msg = MagicMock()
+ msg.content = "Hello world"
+
+ count = get_token_count([msg])
+ assert count > 0
+ # "Hello world" is ~2-3 tokens plus overhead
+ assert count >= 3
+
+ def test_message_with_system_prompt(self):
+ """Test token count includes system prompt."""
+ msg = MagicMock()
+ msg.content = "Hello"
+
+ count = get_token_count([msg], system="You are a helpful assistant")
+ assert count > 0
+
+ def test_message_with_list_content(self):
+ """Test token count with list content blocks."""
+ text_block = MagicMock()
+ text_block.type = "text"
+ text_block.text = "Hello world"
+
+ msg = MagicMock()
+ msg.content = [text_block]
+
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_message_with_thinking_block(self):
+ """Test token count includes thinking blocks."""
+ thinking_block = MagicMock()
+ thinking_block.type = "thinking"
+ thinking_block.thinking = "Let me think about this..."
+
+ msg = MagicMock()
+ msg.content = [thinking_block]
+
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_message_with_tool_use(self):
+ """Test token count includes tool use blocks."""
+ tool_block = MagicMock()
+ tool_block.type = "tool_use"
+ tool_block.name = "search"
+ tool_block.input = {"query": "test"}
+
+ msg = MagicMock()
+ msg.content = [tool_block]
+
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_message_with_tool_result(self):
+ """Test token count includes tool result blocks."""
+ result_block = MagicMock()
+ result_block.type = "tool_result"
+ result_block.content = "Search results here"
+
+ msg = MagicMock()
+ msg.content = [result_block]
+
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_message_with_tools(self):
+ """Test token count includes tool definitions."""
+ msg = MagicMock()
+ msg.content = "Use the search tool"
+
+ tool = MagicMock()
+ tool.name = "search"
+ tool.description = "Search for information"
+ tool.input_schema = {"type": "object", "properties": {}}
+
+ count = get_token_count([msg], tools=[tool])
+ assert count > 0
+
+ def test_system_as_list(self):
+ """Test token count with system as list of blocks."""
+ msg = MagicMock()
+ msg.content = "Hello"
+
+ block = MagicMock()
+ block.text = "System prompt"
+
+ count = get_token_count([msg], system=[block])
+ assert count > 0
+
+ def test_tool_result_with_dict_content(self):
+ """Test token count with tool result containing dict content."""
+ result_block = MagicMock()
+ result_block.type = "tool_result"
+ result_block.content = {"result": "data"}
+
+ msg = MagicMock()
+ msg.content = [result_block]
+
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_multiple_messages_overhead(self):
+ """Test that multiple messages include overhead."""
+ msg1 = MagicMock()
+ msg1.content = "Hi"
+
+ msg2 = MagicMock()
+ msg2.content = "Hello"
+
+ count_single = get_token_count([msg1])
+ count_double = get_token_count([msg1, msg2])
+
+ # Double message should have more tokens (including overhead)
+ assert count_double > count_single
+
+ def test_per_message_overhead_four_tokens(self):
+ """Per-message overhead is 4 tokens (was 3)."""
+ msg = MagicMock()
+ msg.content = "x" # Minimal content
+ count = get_token_count([msg])
+ # 1 msg * 4 overhead + content tokens
+ assert count >= 5
+
+ def test_system_overhead_added(self):
+ """System prompt adds ~4 tokens overhead."""
+ msg = MagicMock()
+ msg.content = "Hi"
+ count_no_sys = get_token_count([msg])
+ count_with_sys = get_token_count([msg], system="You are helpful")
+ assert count_with_sys >= count_no_sys + 4
+
+ def test_system_as_list_of_dicts(self):
+ """System blocks as dicts (not objects) are counted."""
+ msg = MagicMock()
+ msg.content = "Hi"
+ count_no_sys = get_token_count([msg])
+ system_dicts = [{"type": "text", "text": "System prompt from dict"}]
+ count_with_dict_sys = get_token_count([msg], system=system_dicts)
+ assert count_with_dict_sys > count_no_sys
+
+ def test_tool_use_includes_id(self):
+ """Tool use blocks count id field."""
+ tool_block = MagicMock()
+ tool_block.type = "tool_use"
+ tool_block.name = "search"
+ tool_block.input = {"q": "test"}
+ tool_block.id = "call_abc123"
+ msg = MagicMock()
+ msg.content = [tool_block]
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_tool_result_includes_tool_use_id(self):
+ """Tool result blocks count tool_use_id field."""
+ result_block = MagicMock()
+ result_block.type = "tool_result"
+ result_block.content = "ok"
+ result_block.tool_use_id = "call_xyz"
+ msg = MagicMock()
+ msg.content = [result_block]
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_unrecognized_block_type_fallback(self):
+ """Unrecognized block types are tokenized via json.dumps fallback."""
+ unknown_block = {"type": "custom", "spec": "data"}
+ msg = MagicMock()
+ msg.content = [unknown_block]
+ count = get_token_count([msg])
+ assert count > 0
+
+ def test_message_with_image_block(self):
+ """Test token count includes image blocks."""
+ image_block = MagicMock()
+ image_block.type = "image"
+ image_block.source = {
+ "type": "base64",
+ "media_type": "image/png",
+ "data": "x" * 3000,
+ }
+ msg = MagicMock()
+ msg.content = [image_block]
+ count = get_token_count([msg])
+ assert count >= 85
+
+ def test_image_block_with_dict_source(self):
+ """Image block with dict-style source is counted."""
+ image_block = {"type": "image", "source": {"data": "a" * 10000}}
+ msg = MagicMock()
+ msg.content = [image_block]
+ count = get_token_count([msg])
+ assert count >= 85
+
+ def test_known_payload_estimate_range(self):
+ """Known payload produces estimate within expected range (validation harness)."""
+ import tiktoken
+
+ enc = tiktoken.get_encoding("cl100k_base")
+ system_text = "You are a helpful assistant."
+ user_text = "Hello, how are you?"
+ sys_tokens = len(enc.encode(system_text))
+ user_tokens = len(enc.encode(user_text))
+ # Min: content tokens + system overhead (4) + per-msg overhead (4)
+ expected_min = sys_tokens + user_tokens + 4 + 4
+ msg = MagicMock()
+ msg.content = user_text
+ count = get_token_count([msg], system=system_text)
+ assert count >= expected_min, f"count={count} < expected_min={expected_min}"
+
+
+# --- Parametrized Edge Case Tests ---
+
+
+@pytest.mark.parametrize(
+ "command,expected",
+ [
+ ("git status", "git status"),
+ ("ls -la", "ls"),
+ ("git commit -m 'msg'", "git commit"),
+ ("npm install pkg", "npm install"),
+ ("ls", "ls"),
+ ("python", "python"),
+ ("", "none"),
+ (" ", "none"),
+ ("`whoami`", "command_injection_detected"),
+ ("$(whoami)", "command_injection_detected"),
+ ("echo $(cat /etc/passwd)", "command_injection_detected"),
+ ("git -v", "git"),
+ ("DEBUG=1 python script.py", "DEBUG=1 python"),
+ ("cargo build", "cargo build"),
+ ("cargo --version", "cargo"),
+ ],
+ ids=[
+ "git_status",
+ "ls_with_flag",
+ "git_commit",
+ "npm_install",
+ "bare_ls",
+ "bare_python",
+ "empty",
+ "whitespace",
+ "injection_backtick",
+ "injection_dollar",
+ "injection_echo",
+ "git_flag",
+ "env_var",
+ "cargo_build",
+ "cargo_flag",
+ ],
+)
+def test_extract_command_prefix_parametrized(command, expected):
+ """Parametrized command prefix extraction."""
+ assert extract_command_prefix(command) == expected
+
+
+def test_extract_command_prefix_unterminated_quote():
+ """Unterminated quote falls back to simple split (shlex.split ValueError)."""
+ result = extract_command_prefix("git commit -m 'unterminated")
+ # Should fall back to command.split()[0] = "git"
+ assert result == "git"
+
+
+def test_extract_command_prefix_pipe():
+ """Piped commands - shlex handles pipe character."""
+ result = extract_command_prefix("cat file.txt | grep pattern")
+ assert result in ("cat", "cat file.txt")
+
+
+@pytest.mark.parametrize(
+ "content,max_tokens,role,expected",
+ [
+ ("Check my quota", 1, "user", True),
+ ("Check my QUOTA", 1, "user", True),
+ ("Hello world", 1, "user", False),
+ ("Check my quota", 100, "user", False),
+ ("Check my quota", 1, "assistant", False),
+ ],
+ ids=["basic", "case_insensitive", "no_keyword", "wrong_max_tokens", "wrong_role"],
+)
+def test_quota_check_parametrized(content, max_tokens, role, expected):
+ """Parametrized quota check request detection."""
+ msg = MagicMock(spec=Message)
+ msg.role = role
+ msg.content = content
+
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = max_tokens
+ req.messages = [msg]
+
+ assert is_quota_check_request(req) is expected
+
+
+def test_quota_check_empty_messages():
+ """Quota check with empty message list should not crash."""
+ req = MagicMock(spec=MessagesRequest)
+ req.max_tokens = 1
+ req.messages = []
+ assert is_quota_check_request(req) is False
diff --git a/Claude_Code/tests/api/test_request_utils_filepaths_and_suggestions.py b/Claude_Code/tests/api/test_request_utils_filepaths_and_suggestions.py
new file mode 100644
index 0000000000000000000000000000000000000000..64c13f27bcb7bc272d274bc947ffece32cbe2f81
--- /dev/null
+++ b/Claude_Code/tests/api/test_request_utils_filepaths_and_suggestions.py
@@ -0,0 +1,130 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from api.command_utils import extract_filepaths_from_command
+from api.detection import (
+ is_filepath_extraction_request,
+ is_suggestion_mode_request,
+)
+from api.models.anthropic import Message, MessagesRequest
+
+
+def _mk_req(messages, tools=None, system=None):
+ req = MagicMock(spec=MessagesRequest)
+ req.messages = messages
+ req.tools = tools
+ req.system = system
+ return req
+
+
+def _mk_msg(role: str, content):
+ msg = MagicMock(spec=Message)
+ msg.role = role
+ msg.content = content
+ return msg
+
+
+class TestSuggestionMode:
+ def test_detects_suggestion_mode_in_any_user_message(self):
+ req = _mk_req(
+ [
+ _mk_msg("assistant", "ignore"),
+ _mk_msg("user", "Hello\n[SUGGESTION MODE: on]\nworld"),
+ ]
+ )
+ assert is_suggestion_mode_request(req) is True
+
+ def test_suggestion_mode_ignores_non_user_messages(self):
+ req = _mk_req([_mk_msg("assistant", "[SUGGESTION MODE: on]")])
+ assert is_suggestion_mode_request(req) is False
+
+
+class TestFilepathExtractionDetection:
+ def test_rejects_when_tools_present(self):
+ msg = _mk_msg(
+ "user",
+ "Command: cat foo.txt\nOutput: hi\n\nPlease extract .",
+ )
+ req = _mk_req([msg], tools=[{"name": "search"}])
+ ok, cmd, out = is_filepath_extraction_request(req)
+ assert (ok, cmd, out) == (False, "", "")
+
+ def test_rejects_when_missing_output_marker(self):
+ msg = _mk_msg(
+ "user",
+ "Command: cat foo.txt\n(no output marker)\n",
+ )
+ req = _mk_req([msg], tools=None)
+ ok, cmd, out = is_filepath_extraction_request(req)
+ assert (ok, cmd, out) == (False, "", "")
+
+ def test_rejects_when_not_asking_for_filepaths(self):
+ msg = _mk_msg("user", "Command: cat foo.txt\nOutput: hi")
+ req = _mk_req([msg], tools=None)
+ ok, cmd, out = is_filepath_extraction_request(req)
+ assert (ok, cmd, out) == (False, "", "")
+
+ def test_detects_filepath_extraction_via_system_block(self):
+ """Command: + Output: in user, no filepaths in user; system has extract instructions."""
+ msg = _mk_msg("user", "Command: ls\nOutput: avazu-ctr\nfree-claude-code")
+ req = _mk_req(
+ [msg],
+ tools=None,
+ system="Extract any file paths that this command reads or modifies.",
+ )
+ ok, cmd, out = is_filepath_extraction_request(req)
+ assert ok is True
+ assert cmd == "ls"
+ assert "avazu-ctr" in out
+ assert "free-claude-code" in out
+
+ def test_extracts_command_and_output_and_cleans_output(self):
+ msg = _mk_msg(
+ "user",
+ "Command: cat foo.txt\n"
+ "Output: line1\nline2\n\n"
+ "Please extract .\n"
+ "ignore me",
+ )
+ req = _mk_req([msg], tools=None)
+ ok, cmd, out = is_filepath_extraction_request(req)
+ assert ok is True
+ assert cmd == "cat foo.txt"
+ assert out == "line1\nline2"
+
+
+class TestExtractFilepathsFromCommand:
+ @pytest.mark.parametrize(
+ "command,expected_paths",
+ [
+ ("ls -la", []),
+ ("dir .", []),
+ ("cat foo.txt", ["foo.txt"]),
+ ("cat -n foo.txt bar.md", ["foo.txt", "bar.md"]),
+ ("type C:\\tmp\\a.txt", ["C:\\tmp\\a.txt"]),
+ ("grep pattern file1.txt file2.txt", ["file1.txt", "file2.txt"]),
+ ("grep -n pattern file.txt", ["file.txt"]),
+ ("grep -e pattern file.txt", ["file.txt"]),
+ ("unknowncmd arg1 arg2", []),
+ ("", []),
+ ],
+ ids=[
+ "listing_ls",
+ "listing_dir",
+ "read_cat",
+ "read_cat_flags",
+ "read_type_windows_path",
+ "grep_simple",
+ "grep_with_flag",
+ "grep_with_e",
+ "unknown",
+ "empty",
+ ],
+ )
+ def test_extracts_expected_paths(self, command, expected_paths):
+ result = extract_filepaths_from_command(command, output="(ignored)")
+ for p in expected_paths:
+ assert p in result
+ if not expected_paths:
+ assert result.strip() == "\n"
diff --git a/Claude_Code/tests/api/test_response_models.py b/Claude_Code/tests/api/test_response_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..e211004927c994fee8c4700464371da88363d6ae
--- /dev/null
+++ b/Claude_Code/tests/api/test_response_models.py
@@ -0,0 +1,199 @@
+"""Tests for api/models/responses.py Pydantic response models."""
+
+from api.models.anthropic import (
+ ContentBlockText,
+ ContentBlockThinking,
+ ContentBlockToolUse,
+)
+from api.models.responses import MessagesResponse, TokenCountResponse, Usage
+
+
+class TestUsage:
+ """Tests for Usage model."""
+
+ def test_required_fields(self):
+ usage = Usage(input_tokens=10, output_tokens=20)
+ assert usage.input_tokens == 10
+ assert usage.output_tokens == 20
+
+ def test_cache_defaults_zero(self):
+ usage = Usage(input_tokens=1, output_tokens=2)
+ assert usage.cache_creation_input_tokens == 0
+ assert usage.cache_read_input_tokens == 0
+
+ def test_cache_fields_set(self):
+ usage = Usage(
+ input_tokens=10,
+ output_tokens=20,
+ cache_creation_input_tokens=5,
+ cache_read_input_tokens=3,
+ )
+ assert usage.cache_creation_input_tokens == 5
+ assert usage.cache_read_input_tokens == 3
+
+ def test_serialization(self):
+ usage = Usage(input_tokens=10, output_tokens=20)
+ data = usage.model_dump()
+ assert data == {
+ "input_tokens": 10,
+ "output_tokens": 20,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ }
+
+
+class TestTokenCountResponse:
+ """Tests for TokenCountResponse model."""
+
+ def test_basic(self):
+ resp = TokenCountResponse(input_tokens=42)
+ assert resp.input_tokens == 42
+
+ def test_serialization(self):
+ resp = TokenCountResponse(input_tokens=100)
+ data = resp.model_dump()
+ assert data == {"input_tokens": 100}
+
+
+class TestMessagesResponse:
+ """Tests for MessagesResponse model."""
+
+ def test_minimum_fields(self):
+ resp = MessagesResponse(
+ id="msg_001",
+ model="test-model",
+ content=[ContentBlockText(type="text", text="Hello")],
+ usage=Usage(input_tokens=10, output_tokens=5),
+ )
+ assert resp.id == "msg_001"
+ assert resp.model == "test-model"
+ assert resp.role == "assistant"
+ assert resp.type == "message"
+ assert resp.stop_reason is None
+ assert resp.stop_sequence is None
+
+ def test_with_text_content(self):
+ resp = MessagesResponse(
+ id="msg_002",
+ model="model",
+ content=[ContentBlockText(type="text", text="response")],
+ usage=Usage(input_tokens=1, output_tokens=1),
+ )
+ assert len(resp.content) == 1
+ block = resp.content[0]
+ assert isinstance(block, ContentBlockText)
+ assert block.type == "text"
+ assert block.text == "response"
+
+ def test_with_tool_use_content(self):
+ resp = MessagesResponse(
+ id="msg_003",
+ model="model",
+ content=[
+ ContentBlockToolUse(
+ type="tool_use",
+ id="tool_1",
+ name="Read",
+ input={"path": "test.py"},
+ )
+ ],
+ usage=Usage(input_tokens=1, output_tokens=1),
+ stop_reason="tool_use",
+ )
+ block = resp.content[0]
+ assert isinstance(block, ContentBlockToolUse)
+ assert block.type == "tool_use"
+ assert block.name == "Read"
+ assert resp.stop_reason == "tool_use"
+
+ def test_with_thinking_content(self):
+ resp = MessagesResponse(
+ id="msg_004",
+ model="model",
+ content=[
+ ContentBlockThinking(type="thinking", thinking="Let me reason..."),
+ ContentBlockText(type="text", text="Answer"),
+ ],
+ usage=Usage(input_tokens=5, output_tokens=10),
+ )
+ assert len(resp.content) == 2
+ block0 = resp.content[0]
+ assert isinstance(block0, ContentBlockThinking)
+ assert block0.type == "thinking"
+ assert block0.thinking == "Let me reason..."
+ block1 = resp.content[1]
+ assert isinstance(block1, ContentBlockText)
+ assert block1.type == "text"
+
+ def test_with_all_content_types(self):
+ resp = MessagesResponse(
+ id="msg_005",
+ model="model",
+ content=[
+ ContentBlockThinking(type="thinking", thinking="hmm"),
+ ContentBlockText(type="text", text="result"),
+ ContentBlockToolUse(
+ type="tool_use", id="t1", name="Bash", input={"command": "ls"}
+ ),
+ ],
+ usage=Usage(input_tokens=10, output_tokens=20),
+ stop_reason="tool_use",
+ )
+ assert len(resp.content) == 3
+
+ def test_with_dict_content(self):
+ """Dict content (unknown block type) should be accepted."""
+ resp = MessagesResponse(
+ id="msg_006",
+ model="model",
+ content=[{"type": "custom", "data": "value"}],
+ usage=Usage(input_tokens=1, output_tokens=1),
+ )
+ block = resp.content[0]
+ assert isinstance(block, dict)
+ assert block["type"] == "custom"
+
+ def test_stop_reason_values(self):
+ """All valid stop_reason values should be accepted."""
+ from typing import Literal
+
+ reasons: list[
+ Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]
+ ] = [
+ "end_turn",
+ "max_tokens",
+ "stop_sequence",
+ "tool_use",
+ ]
+ for reason in reasons:
+ resp = MessagesResponse(
+ id="msg",
+ model="model",
+ content=[ContentBlockText(type="text", text="x")],
+ usage=Usage(input_tokens=1, output_tokens=1),
+ stop_reason=reason,
+ )
+ assert resp.stop_reason == reason
+
+ def test_serialization_round_trip(self):
+ resp = MessagesResponse(
+ id="msg_rt",
+ model="model-v1",
+ content=[ContentBlockText(type="text", text="hello")],
+ usage=Usage(input_tokens=10, output_tokens=5),
+ stop_reason="end_turn",
+ )
+ data = resp.model_dump()
+ restored = MessagesResponse(**data)
+ assert restored.id == resp.id
+ assert restored.model == resp.model
+ assert restored.stop_reason == resp.stop_reason
+
+ def test_empty_content_list(self):
+ resp = MessagesResponse(
+ id="msg_empty",
+ model="model",
+ content=[],
+ usage=Usage(input_tokens=0, output_tokens=0),
+ )
+ assert resp.content == []
diff --git a/Claude_Code/tests/api/test_routes_optimizations.py b/Claude_Code/tests/api/test_routes_optimizations.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0c058c09501337197e6966a9fc84b183d366639
--- /dev/null
+++ b/Claude_Code/tests/api/test_routes_optimizations.py
@@ -0,0 +1,167 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from api.app import app
+from api.dependencies import get_settings
+from config.settings import Settings
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+@pytest.fixture
+def mock_settings():
+ settings = Settings()
+ settings.fast_prefix_detection = True
+ settings.enable_network_probe_mock = True
+ settings.enable_title_generation_skip = True
+ return settings
+
+
+def test_create_message_fast_prefix_detection(client, mock_settings):
+ app.dependency_overrides[get_settings] = lambda: mock_settings
+
+ payload = {
+ "model": "claude-3-sonnet",
+ "max_tokens": 100,
+ "messages": [{"role": "user", "content": "What is the prefix?"}],
+ }
+
+ with (
+ patch(
+ "api.optimization_handlers.is_prefix_detection_request",
+ return_value=(True, "/ask"),
+ ),
+ patch(
+ "api.optimization_handlers.extract_command_prefix",
+ return_value="/ask",
+ ),
+ ):
+ response = client.post("/v1/messages", json=payload)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "/ask" in data["content"][0]["text"]
+
+ app.dependency_overrides.clear()
+
+
+def test_create_message_quota_check_mock(client, mock_settings):
+ app.dependency_overrides[get_settings] = lambda: mock_settings
+
+ payload = {
+ "model": "claude-3-sonnet",
+ "max_tokens": 100,
+ "messages": [{"role": "user", "content": "quota check"}],
+ }
+
+ with patch("api.optimization_handlers.is_quota_check_request", return_value=True):
+ response = client.post("/v1/messages", json=payload)
+
+ assert response.status_code == 200
+ assert "Quota check passed" in response.json()["content"][0]["text"]
+
+ app.dependency_overrides.clear()
+
+
+def test_create_message_title_generation_skip(client, mock_settings):
+ app.dependency_overrides[get_settings] = lambda: mock_settings
+
+ payload = {
+ "model": "claude-3-sonnet",
+ "max_tokens": 100,
+ "messages": [{"role": "user", "content": "generate title"}],
+ }
+
+ with patch(
+ "api.optimization_handlers.is_title_generation_request", return_value=True
+ ):
+ response = client.post("/v1/messages", json=payload)
+
+ assert response.status_code == 200
+ assert "Conversation" in response.json()["content"][0]["text"]
+
+ app.dependency_overrides.clear()
+
+
+def test_create_message_empty_messages_returns_400(client):
+ """POST /v1/messages with messages: [] returns 400 invalid_request_error."""
+ payload = {
+ "model": "claude-3-sonnet",
+ "max_tokens": 100,
+ "messages": [],
+ }
+ response = client.post("/v1/messages", json=payload)
+ assert response.status_code == 400
+ data = response.json()
+ assert data.get("type") == "error"
+ assert data.get("error", {}).get("type") == "invalid_request_error"
+ assert "cannot be empty" in data.get("error", {}).get("message", "")
+
+
+def test_count_tokens_endpoint(client):
+ payload = {
+ "model": "claude-3-sonnet",
+ "messages": [{"role": "user", "content": "hello"}],
+ }
+
+ with patch("api.routes.get_token_count", return_value=5):
+ response = client.post("/v1/messages/count_tokens", json=payload)
+
+ assert response.status_code == 200
+ assert response.json()["input_tokens"] == 5
+
+
+def test_count_tokens_error_returns_500(client):
+ """When get_token_count raises, count_tokens returns 500."""
+ payload = {
+ "model": "claude-3-sonnet",
+ "messages": [{"role": "user", "content": "hello"}],
+ }
+
+ with patch("api.routes.get_token_count", side_effect=RuntimeError("token error")):
+ response = client.post("/v1/messages/count_tokens", json=payload)
+
+ assert response.status_code == 500
+ assert "token error" in response.json()["detail"]
+
+
+def test_stop_cli_with_handler(client):
+ mock_handler = MagicMock()
+ # Mock the async method to return a completed future or just mock it since TestClient
+ # will run the app in a way that respects it?
+ # Actually, we need to mock it as an async function.
+ mock_handler.stop_all_tasks = AsyncMock(return_value=3)
+ app.state.message_handler = mock_handler
+
+ response = client.post("/stop")
+
+ assert response.status_code == 200
+ assert response.json()["cancelled_count"] == 3
+ mock_handler.stop_all_tasks.assert_called_once()
+
+ # Cleanup state
+ if hasattr(app.state, "message_handler"):
+ del app.state.message_handler
+
+
+def test_stop_cli_fallback_to_manager(client):
+ if hasattr(app.state, "message_handler"):
+ del app.state.message_handler
+
+ mock_manager = MagicMock()
+ mock_manager.stop_all = AsyncMock()
+ app.state.cli_manager = mock_manager
+
+ response = client.post("/stop")
+
+ assert response.status_code == 200
+ assert response.json()["source"] == "cli_manager"
+ mock_manager.stop_all.assert_called_once()
+
+ if hasattr(app.state, "cli_manager"):
+ del app.state.cli_manager
diff --git a/Claude_Code/tests/api/test_server_module.py b/Claude_Code/tests/api/test_server_module.py
new file mode 100644
index 0000000000000000000000000000000000000000..887dfecc8db35205fca7ea9a142c8685744f191c
--- /dev/null
+++ b/Claude_Code/tests/api/test_server_module.py
@@ -0,0 +1,33 @@
+def test_server_module_exports_app_and_create_app():
+ import server
+
+ assert server.app is not None
+ assert callable(server.create_app)
+
+
+def test_server_main_invokes_uvicorn_run(monkeypatch):
+ import runpy
+ from types import SimpleNamespace
+ from unittest.mock import patch
+
+ import uvicorn as uvicorn_mod
+
+ import config.settings as settings_mod
+
+ # Patch settings used by server.__main__ block.
+ old_get_settings = settings_mod.get_settings
+ mock_settings = SimpleNamespace(host="127.0.0.1", port=9999)
+
+ try:
+ with (
+ patch.object(settings_mod, "get_settings", lambda: mock_settings),
+ patch.object(uvicorn_mod, "run") as mock_run,
+ ):
+ runpy.run_module("server", run_name="__main__")
+ mock_run.assert_called_once()
+ call_kwargs = mock_run.call_args[1]
+ assert call_kwargs["host"] == "127.0.0.1"
+ assert call_kwargs["port"] == 9999
+ assert call_kwargs["log_level"] == "debug"
+ finally:
+ settings_mod.get_settings = old_get_settings
diff --git a/Claude_Code/tests/cli/test_cli.py b/Claude_Code/tests/cli/test_cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..a22f9dd1c8cb1e73f472fc242fbfd6f4b92977fb
--- /dev/null
+++ b/Claude_Code/tests/cli/test_cli.py
@@ -0,0 +1,595 @@
+"""Tests for cli/ module."""
+
+import asyncio
+import json
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from messaging.event_parser import parse_cli_event
+
+# --- Existing Parser Tests ---
+
+
+class TestCLIParser:
+ """Test CLI event parsing."""
+
+ def test_parse_text_content(self):
+ """Test parsing text content from assistant message."""
+ event = {
+ "type": "assistant",
+ "message": {"content": [{"type": "text", "text": "Hello, world!"}]},
+ }
+ result = parse_cli_event(event)
+ assert len(result) == 1
+ assert result[0]["type"] == "text_chunk"
+ assert result[0]["text"] == "Hello, world!"
+
+ def test_parse_thinking_content(self):
+ """Test parsing thinking content."""
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [{"type": "thinking", "thinking": "Let me think..."}]
+ },
+ }
+ result = parse_cli_event(event)
+ assert len(result) == 1
+ assert result[0]["type"] == "thinking_chunk"
+ assert (
+ result[0]["text"] == "Let me think...\n"
+ or result[0]["text"] == "Let me think..."
+ )
+
+ def test_parse_multiple_content(self):
+ """Test parsing mixed content (thinking + tools)."""
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [
+ {"type": "thinking", "thinking": "Thinking..."},
+ {"type": "tool_use", "name": "ls", "input": {}},
+ ]
+ },
+ }
+ result = parse_cli_event(event)
+ assert len(result) == 2
+ assert result[0]["type"] == "thinking_chunk"
+ assert result[0]["text"] == "Thinking..."
+ assert result[1]["type"] == "tool_use"
+
+ def test_parse_tool_use(self):
+ """Test parsing tool use content."""
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [
+ {
+ "type": "tool_use",
+ "name": "read_file",
+ "input": {"path": "/test"},
+ }
+ ]
+ },
+ }
+ result = parse_cli_event(event)
+ assert len(result) == 1
+ assert result[0]["type"] == "tool_use"
+ assert result[0]["name"] == "read_file"
+ assert result[0]["input"] == {"path": "/test"}
+
+ def test_parse_text_delta(self):
+ """Test parsing streaming text delta."""
+ event = {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": "streaming text"},
+ }
+ result = parse_cli_event(event)
+ assert len(result) == 1
+ assert result[0]["type"] == "text_delta"
+ assert result[0]["text"] == "streaming text"
+
+ def test_parse_thinking_delta(self):
+ """Test parsing streaming thinking delta."""
+ event = {
+ "type": "content_block_delta",
+ "index": 1,
+ "delta": {"type": "thinking_delta", "thinking": "thinking..."},
+ }
+ result = parse_cli_event(event)
+ assert len(result) == 1
+ assert result[0]["type"] == "thinking_delta"
+ assert result[0]["text"] == "thinking..."
+
+ def test_parse_error(self):
+ """Test parsing error event."""
+ event = {"type": "error", "error": {"message": "Something went wrong"}}
+ result = parse_cli_event(event)
+ assert result[0]["type"] == "error"
+ assert result[0]["message"] == "Something went wrong"
+
+ def test_parse_exit_success(self):
+ """Test parsing exit event with success."""
+ event = {"type": "exit", "code": 0}
+ result = parse_cli_event(event)
+ assert result[0]["type"] == "complete"
+ assert result[0]["status"] == "success"
+
+ def test_parse_exit_failure(self):
+ """Test parsing exit event with failure returns error then complete."""
+ event = {"type": "exit", "code": 1}
+ result = parse_cli_event(event)
+ # Non-zero exit now returns error first, then complete
+ assert len(result) == 2
+ assert result[0]["type"] == "error"
+ assert (
+ "exit" in result[0]["message"].lower()
+ or "code" in result[0]["message"].lower()
+ )
+ assert result[1]["type"] == "complete"
+ assert result[1]["status"] == "failed"
+
+ def test_parse_invalid_event(self):
+ """Test parsing returns empty list for unrecognized event."""
+ result = parse_cli_event({"type": "unknown"})
+ assert result == []
+
+ def test_parse_non_dict(self):
+ """Test parsing returns empty list for non-dict input."""
+ result = parse_cli_event("not a dict")
+ assert result == []
+
+
+# --- CLI Session Tests ---
+
+
+class TestCLISession:
+ """Test CLISession."""
+
+ def test_session_init(self):
+ """Test CLISession initialization."""
+ from cli.session import CLISession
+
+ session = CLISession(
+ workspace_path="/tmp/test",
+ api_url="http://localhost:8082/v1",
+ allowed_dirs=["/home/user/projects"],
+ )
+ assert session.workspace == os.path.normpath(os.path.abspath("/tmp/test"))
+ assert session.api_url == "http://localhost:8082/v1"
+ assert not session.is_busy
+
+ def test_session_extract_session_id(self):
+ """Test session ID extraction from various event formats."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ # Direct session_id field
+ assert session._extract_session_id({"session_id": "abc123"}) == "abc123"
+ assert session._extract_session_id({"sessionId": "abc123"}) == "abc123"
+
+ # Nested in init
+ assert (
+ session._extract_session_id({"init": {"session_id": "nested123"}})
+ == "nested123"
+ )
+
+ # Nested in result
+ assert (
+ session._extract_session_id({"result": {"session_id": "res123"}})
+ == "res123"
+ )
+
+ # Conversation id
+ assert (
+ session._extract_session_id({"conversation": {"id": "conv123"}})
+ == "conv123"
+ )
+
+ # No session ID
+ assert session._extract_session_id({"type": "message"}) is None
+ assert session._extract_session_id("not a dict") is None
+
+ @pytest.mark.asyncio
+ async def test_start_task_basic_flow(self):
+ """Test start_task running a basic command flow."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ # Mock subprocess
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [
+ b'{"type": "message", "content": "Hello"}\n',
+ b'{"session_id": "sess_1"}\n',
+ b"", # EOF
+ ]
+ mock_process.stderr.read.return_value = b"" # No error
+ mock_process.wait.return_value = 0
+ mock_process.returncode = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ events = [e async for e in session.start_task("Hello")]
+
+ # Verify command construction
+ # Arg 1 is subprocess command
+ args = mock_exec.call_args[0]
+ assert args[0] == "claude"
+ assert "-p" in args
+ assert "Hello" in args
+
+ # Verify events
+ assert (
+ len(events) == 4
+ ) # message, session_id, session_info (synthesized), exit
+ assert events[0] == {"type": "message", "content": "Hello"}
+ assert events[1] == {"type": "session_info", "session_id": "sess_1"}
+ # The session_info event is yielded by _handle_line_gen right after extracting ID
+ assert events[2] == {"session_id": "sess_1"} # The original event
+ assert events[3] == {"type": "exit", "code": 0, "stderr": None}
+
+ assert session.current_session_id == "sess_1"
+
+ @pytest.mark.asyncio
+ async def test_start_task_with_session_resume(self):
+ """Test resuming an existing session."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [
+ b"",
+ ] # Immediate EOF
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ async for _ in session.start_task("Hello", session_id="sess_abc"):
+ pass
+
+ args = mock_exec.call_args[0]
+ assert "--resume" in args
+ assert "sess_abc" in args
+ assert "--fork-session" not in args
+
+ @pytest.mark.asyncio
+ async def test_start_task_with_session_resume_and_fork(self):
+ """Test resuming an existing session and forking."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [b""] # Immediate EOF
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ async for _ in session.start_task(
+ "Hello", session_id="sess_abc", fork_session=True
+ ):
+ pass
+
+ args = mock_exec.call_args[0]
+ assert "--resume" in args
+ assert "sess_abc" in args
+ assert "--fork-session" in args
+
+ @pytest.mark.asyncio
+ async def test_start_task_process_failure_with_stderr(self):
+ """Test process exit with error code and stderr output."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [b""] # No stdout
+ mock_process.stderr.read.return_value = b"Fatal error"
+ mock_process.wait.return_value = 1
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ events = [e async for e in session.start_task("Hello")]
+
+ # Should have error event from stderr, then exit event
+ assert len(events) == 2
+ assert events[0]["type"] == "error"
+ assert events[0]["error"]["message"] == "Fatal error"
+
+ assert events[1]["type"] == "exit"
+ assert events[1]["code"] == 1
+ assert events[1]["stderr"] == "Fatal error"
+
+ @pytest.mark.asyncio
+ async def test_stop_session(self):
+ """Test stopping the session process."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = MagicMock()
+ mock_process.returncode = None # Running
+ # Mock wait to simulate async finish
+ mock_process.wait = AsyncMock(return_value=0)
+
+ session.process = mock_process
+
+ stopped = await session.stop()
+
+ assert stopped is True
+ mock_process.terminate.assert_called_once()
+ mock_process.wait.assert_called()
+
+ @pytest.mark.asyncio
+ async def test_stop_session_timeout_force_kill(self):
+ """Test force kill if terminate times out."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = MagicMock()
+ mock_process.returncode = None
+
+ # First wait times out
+ async def wait_side_effect():
+ if not mock_process.kill.called:
+ await asyncio.sleep(6) # Should be > 5.0 timeout
+ return 0
+
+ # We can simulate timeout by raising TimeoutError directly on first call
+ mock_process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
+
+ session.process = mock_process
+
+ stopped = await session.stop()
+
+ assert stopped is True
+ mock_process.terminate.assert_called()
+ mock_process.kill.assert_called()
+
+ @pytest.mark.asyncio
+ async def test_start_task_split_buffer(self):
+ """Test handling of JSON split across chunks."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = AsyncMock()
+ # Split json: {"type": "mess... age"}
+ mock_process.stdout.read.side_effect = [
+ b'{"type": "mess',
+ b'age", "content": "Split"}\n',
+ b"",
+ ]
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ events = [
+ e async for e in session.start_task("test") if e["type"] == "message"
+ ]
+
+ assert len(events) == 1
+ assert events[0]["content"] == "Split"
+
+ @pytest.mark.asyncio
+ async def test_start_task_remnant_buffer(self):
+ """Test handling of buffer remnant at EOF (no newline at end)."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [
+ b'{"type": "message", "content": "Remnant"}', # No newline
+ b"",
+ ]
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ events = [
+ e async for e in session.start_task("test") if e["type"] == "message"
+ ]
+
+ assert len(events) == 1
+ assert events[0]["content"] == "Remnant"
+
+ @pytest.mark.asyncio
+ async def test_start_task_non_v1_url(self):
+ """Test start_task with a non-v1 URL."""
+ from cli.session import CLISession
+
+ # URL not ending in /v1
+ session = CLISession("/tmp", "http://localhost:8082")
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [b""]
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+ async for _ in session.start_task("test"):
+ pass
+
+ # Check env var
+ kwargs = mock_exec.call_args[1]
+ env = kwargs["env"]
+ assert env["ANTHROPIC_BASE_URL"] == "http://localhost:8082"
+
+ @pytest.mark.asyncio
+ async def test_start_task_allowed_dirs(self):
+ """Test start_task includes allowed dirs in command."""
+ from cli.session import CLISession
+
+ session = CLISession(
+ "/tmp", "http://localhost:8082/v1", allowed_dirs=["/dir1", "/dir2"]
+ )
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [b""]
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+ async for _ in session.start_task("test"):
+ pass
+
+ cmd = mock_exec.call_args[0]
+ assert "--add-dir" in cmd
+ assert os.path.normpath("/dir1") in cmd
+ assert os.path.normpath("/dir2") in cmd
+
+ @pytest.mark.asyncio
+ async def test_start_task_plans_directory(self):
+ """Test start_task includes --settings plansDirectory when plans_directory set."""
+ from cli.session import CLISession
+
+ session = CLISession(
+ "/tmp",
+ "http://localhost:8082/v1",
+ plans_directory="./agent_workspace/plans",
+ )
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [b""]
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+ async for _ in session.start_task("test"):
+ pass
+
+ cmd = mock_exec.call_args[0]
+ assert "--settings" in cmd
+ settings_idx = cmd.index("--settings")
+ assert settings_idx + 1 < len(cmd)
+ settings = json.loads(cmd[settings_idx + 1])
+ assert settings["plansDirectory"] == "./agent_workspace/plans"
+
+ @pytest.mark.asyncio
+ async def test_start_task_json_error(self):
+ """Test handling of non-JSON output from CLI."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = AsyncMock()
+ mock_process.stdout.read.side_effect = [b"Not valid json\n", b""]
+ mock_process.stderr.read.return_value = b""
+ mock_process.wait.return_value = 0
+
+ with patch(
+ "asyncio.create_subprocess_exec", new_callable=AsyncMock
+ ) as mock_exec:
+ mock_exec.return_value = mock_process
+
+ events = [e async for e in session.start_task("test") if e["type"] == "raw"]
+
+ assert len(events) == 1
+ assert events[0]["content"] == "Not valid json"
+
+ @pytest.mark.asyncio
+ async def test_stop_exception(self):
+ """Test exception handling during stop."""
+ from cli.session import CLISession
+
+ session = CLISession("/tmp", "http://localhost:8082/v1")
+
+ mock_process = MagicMock()
+ mock_process.returncode = None
+ # Raise exception on terminate
+ mock_process.terminate.side_effect = RuntimeError("Permission denied")
+
+ session.process = mock_process
+
+ stopped = await session.stop()
+ assert stopped is False
+
+
+class TestCLISessionManager:
+ """Test CLISessionManager."""
+
+ @pytest.mark.asyncio
+ async def test_manager_create_session(self):
+ """Test creating a new session."""
+ from cli.manager import CLISessionManager
+
+ manager = CLISessionManager(
+ workspace_path="/tmp/test",
+ api_url="http://localhost:8082/v1",
+ )
+
+ session, sid, is_new = await manager.get_or_create_session()
+ assert session is not None
+ assert sid.startswith("pending_")
+ assert is_new is True
+
+ @pytest.mark.asyncio
+ async def test_manager_reuse_session(self):
+ """Test reusing an existing session."""
+ from cli.manager import CLISessionManager
+
+ manager = CLISessionManager(
+ workspace_path="/tmp/test",
+ api_url="http://localhost:8082/v1",
+ )
+
+ # Create first session
+ s1, sid1, _is_new1 = await manager.get_or_create_session()
+
+ # Request same session
+ s2, _sid2, is_new2 = await manager.get_or_create_session(session_id=sid1)
+
+ assert s1 is s2
+ assert is_new2 is False
+
+ @pytest.mark.asyncio
+ async def test_manager_stats(self):
+ """Test manager stats."""
+ from cli.manager import CLISessionManager
+
+ manager = CLISessionManager(
+ workspace_path="/tmp/test",
+ api_url="http://localhost:8082/v1",
+ )
+
+ stats = manager.get_stats()
+ assert stats["active_sessions"] == 0
+ assert stats["pending_sessions"] == 0
diff --git a/Claude_Code/tests/cli/test_cli_manager_edge_cases.py b/Claude_Code/tests/cli/test_cli_manager_edge_cases.py
new file mode 100644
index 0000000000000000000000000000000000000000..939c1aaad4965a5248efb98ea41990f17db200a8
--- /dev/null
+++ b/Claude_Code/tests/cli/test_cli_manager_edge_cases.py
@@ -0,0 +1,102 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_register_real_session_id_moves_pending_to_active_and_maps():
+ from cli.manager import CLISessionManager
+
+ with patch("cli.manager.CLISession") as mock_session_cls:
+ mock_session = MagicMock()
+ mock_session.is_busy = False
+ mock_session.stop = AsyncMock(return_value=True)
+ mock_session_cls.return_value = mock_session
+
+ manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
+ session, temp_id, is_new = await manager.get_or_create_session()
+ assert session is mock_session
+ assert is_new is True
+
+ ok = await manager.register_real_session_id(temp_id, "real_1")
+ assert ok is True
+
+ # Lookup via temp id should resolve to the real session id.
+ s2, sid2, is_new2 = await manager.get_or_create_session(session_id=temp_id)
+ assert s2 is mock_session
+ assert sid2 == "real_1"
+ assert is_new2 is False
+
+
+@pytest.mark.asyncio
+async def test_register_real_session_id_missing_temp_id_returns_false():
+ from cli.manager import CLISessionManager
+
+ manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
+ ok = await manager.register_real_session_id("missing", "real_1")
+ assert ok is False
+
+
+@pytest.mark.asyncio
+async def test_remove_session_pending_stops_and_returns_true():
+ from cli.manager import CLISessionManager
+
+ with patch("cli.manager.CLISession") as mock_session_cls:
+ mock_session = MagicMock()
+ mock_session.is_busy = False
+ mock_session.stop = AsyncMock(return_value=True)
+ mock_session_cls.return_value = mock_session
+
+ manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
+ _, temp_id, _ = await manager.get_or_create_session()
+
+ removed = await manager.remove_session(temp_id)
+ assert removed is True
+ mock_session.stop.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_remove_session_active_removes_temp_mapping():
+ from cli.manager import CLISessionManager
+
+ with patch("cli.manager.CLISession") as mock_session_cls:
+ mock_session = MagicMock()
+ mock_session.is_busy = False
+ mock_session.stop = AsyncMock(return_value=True)
+ mock_session_cls.return_value = mock_session
+
+ manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
+ _, temp_id, _ = await manager.get_or_create_session()
+ await manager.register_real_session_id(temp_id, "real_1")
+
+ removed = await manager.remove_session("real_1")
+ assert removed is True
+
+ # Temp ID should no longer resolve to an active session after removal.
+ _, sid2, is_new2 = await manager.get_or_create_session(session_id=temp_id)
+ assert sid2 == temp_id
+ assert is_new2 is True
+
+
+@pytest.mark.asyncio
+async def test_stop_all_handles_stop_exceptions():
+ from cli.manager import CLISessionManager
+
+ manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
+
+ s1 = MagicMock()
+ s1.stop = AsyncMock(side_effect=RuntimeError("boom"))
+ s1.is_busy = False
+
+ s2 = MagicMock()
+ s2.stop = AsyncMock(return_value=True)
+ s2.is_busy = False
+
+ manager._sessions["a"] = s1
+ manager._pending_sessions["b"] = s2
+
+ await manager.stop_all()
+ s1.stop.assert_awaited_once()
+ s2.stop.assert_awaited_once()
+ assert manager.get_stats()["active_sessions"] == 0
+ assert manager.get_stats()["pending_sessions"] == 0
diff --git a/Claude_Code/tests/cli/test_entrypoints.py b/Claude_Code/tests/cli/test_entrypoints.py
new file mode 100644
index 0000000000000000000000000000000000000000..2bdc9e84ff25a413cd6d15459df400cbf3c48f1d
--- /dev/null
+++ b/Claude_Code/tests/cli/test_entrypoints.py
@@ -0,0 +1,75 @@
+"""Tests for cli/entrypoints.py — fcc-init scaffolding logic."""
+
+from pathlib import Path
+from unittest.mock import patch
+
+
+def _run_init(tmp_home: Path) -> tuple[str, Path]:
+ """Run init() with home directory redirected to tmp_home. Returns (printed output, env_file path)."""
+ from cli.entrypoints import init
+
+ env_file = tmp_home / ".config" / "free-claude-code" / ".env"
+ printed: list[str] = []
+
+ with (
+ patch("pathlib.Path.home", return_value=tmp_home),
+ patch(
+ "builtins.print",
+ side_effect=lambda *a: printed.append(" ".join(str(x) for x in a)),
+ ),
+ ):
+ init()
+
+ return "\n".join(printed), env_file
+
+
+def test_init_creates_env_file(tmp_path: Path) -> None:
+ """init() creates .env from the bundled template when it doesn't exist yet."""
+ output, env_file = _run_init(tmp_path)
+
+ assert env_file.exists()
+ assert env_file.stat().st_size > 0
+ assert str(env_file) in output
+
+
+def test_init_copies_template_content(tmp_path: Path) -> None:
+ """init() writes the actual bundled env.example content, not an empty file."""
+ import importlib.resources
+
+ template = (
+ importlib.resources.files("config").joinpath("env.example").read_text("utf-8")
+ )
+ _, env_file = _run_init(tmp_path)
+
+ assert env_file.read_text("utf-8") == template
+
+
+def test_init_creates_parent_directories(tmp_path: Path) -> None:
+ """init() creates ~/.config/free-claude-code/ even if it doesn't exist."""
+ config_dir = tmp_path / ".config" / "free-claude-code"
+ assert not config_dir.exists()
+
+ _run_init(tmp_path)
+
+ assert config_dir.is_dir()
+
+
+def test_init_skips_if_env_already_exists(tmp_path: Path) -> None:
+ """init() does not overwrite an existing .env and prints a warning."""
+ # Create it first
+ _run_init(tmp_path)
+
+ env_file = tmp_path / ".config" / "free-claude-code" / ".env"
+ env_file.write_text("existing content", encoding="utf-8")
+
+ output, _ = _run_init(tmp_path)
+
+ assert env_file.read_text("utf-8") == "existing content"
+ assert "already exists" in output
+
+
+def test_init_prints_next_step_hint(tmp_path: Path) -> None:
+ """init() tells the user to run free-claude-code after editing .env."""
+ output, _ = _run_init(tmp_path)
+
+ assert "free-claude-code" in output
diff --git a/Claude_Code/tests/cli/test_process_registry.py b/Claude_Code/tests/cli/test_process_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..36032032376f61b70d110fa5ab069d5a9d9a48d3
--- /dev/null
+++ b/Claude_Code/tests/cli/test_process_registry.py
@@ -0,0 +1,78 @@
+import os
+from unittest.mock import patch
+
+
+def test_process_registry_register_pid_zero_noop():
+ """register_pid(0) is a no-op (early return)."""
+ from cli import process_registry as pr
+
+ before = len(pr._pids)
+ pr.register_pid(0)
+ assert len(pr._pids) == before
+
+
+def test_process_registry_unregister_pid_zero_noop():
+ """unregister_pid(0) is a no-op."""
+ from cli import process_registry as pr
+
+ pr.register_pid(99999)
+ pr.unregister_pid(0)
+ assert 99999 in pr._pids
+ pr.unregister_pid(99999)
+
+
+def test_process_registry_ensure_atexit_idempotent():
+ """Second call to ensure_atexit_registered is idempotent."""
+ from cli import process_registry as pr
+
+ pr.ensure_atexit_registered()
+ pr.ensure_atexit_registered()
+ # Should not raise; atexit handler registered once
+
+
+def test_process_registry_kill_all_exception_logged_no_raise(monkeypatch):
+ """Exception in os.kill/taskkill is logged but does not raise."""
+ from cli import process_registry as pr
+
+ monkeypatch.setattr(pr, "_pids", {99999})
+ monkeypatch.setattr(os, "name", "posix", raising=False)
+
+ def _kill_raises(pid, sig):
+ raise ProcessLookupError("no such process")
+
+ with patch("os.kill", _kill_raises):
+ pr.kill_all_best_effort()
+ # Should not raise
+
+
+def test_process_registry_register_unregister_does_not_crash():
+ from cli import process_registry as pr
+
+ pr.register_pid(12345)
+ pr.unregister_pid(12345)
+
+
+def test_process_registry_kill_all_best_effort_empty_is_noop():
+ from cli import process_registry as pr
+
+ # Ensure no exception on empty set
+ pr.kill_all_best_effort()
+
+
+def test_process_registry_kill_all_best_effort_windows_noop_when_taskkill_missing(
+ monkeypatch,
+):
+ from cli import process_registry as pr
+
+ # Simulate windows path in a stable way.
+ monkeypatch.setattr(pr, "_pids", {12345})
+ monkeypatch.setattr(os, "name", "nt", raising=False)
+
+ # If taskkill isn't callable, we still should not crash.
+ import subprocess
+
+ def _boom(*args, **kwargs):
+ raise FileNotFoundError("taskkill missing")
+
+ monkeypatch.setattr(subprocess, "run", _boom)
+ pr.kill_all_best_effort()
diff --git a/Claude_Code/tests/config/test_config.py b/Claude_Code/tests/config/test_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..cf9e751caea44403273ef792d3f91a876b7381eb
--- /dev/null
+++ b/Claude_Code/tests/config/test_config.py
@@ -0,0 +1,482 @@
+"""Tests for config/settings.py and config/nim.py"""
+
+import pytest
+from pydantic import ValidationError
+
+from config.nim import NimSettings
+
+
+class TestSettings:
+ """Test Settings configuration."""
+
+ def test_settings_loads(self):
+ """Ensure Settings can be instantiated."""
+ from config.settings import Settings
+
+ settings = Settings()
+ assert settings is not None
+
+ def test_default_values(self):
+ """Test default values are set and have correct types."""
+ from config.settings import Settings
+
+ settings = Settings()
+ assert isinstance(settings.provider_rate_limit, int)
+ assert isinstance(settings.provider_rate_window, int)
+ assert isinstance(settings.nim.temperature, float)
+ assert isinstance(settings.fast_prefix_detection, bool)
+
+ def test_get_settings_cached(self):
+ """Test get_settings returns cached instance."""
+ from config.settings import get_settings
+
+ s1 = get_settings()
+ s2 = get_settings()
+ assert s1 is s2 # Same object (cached)
+
+ def test_empty_string_to_none_for_optional_int(self):
+ """Test that empty string converts to None for optional int fields."""
+ from config.settings import Settings
+
+ # Settings should handle NVIDIA_NIM_SEED="" gracefully
+ settings = Settings()
+ assert settings.nim.seed is None or isinstance(settings.nim.seed, int)
+
+ def test_model_setting(self):
+ """Test model setting exists and is a string."""
+ from config.settings import Settings
+
+ settings = Settings()
+ assert isinstance(settings.model, str)
+ assert len(settings.model) > 0
+
+ def test_base_url_constant(self):
+ """Test NVIDIA_NIM_BASE_URL is a constant."""
+ from providers.nvidia_nim import NVIDIA_NIM_BASE_URL
+
+ assert NVIDIA_NIM_BASE_URL == "https://integrate.api.nvidia.com/v1"
+
+ def test_lm_studio_base_url_from_env(self, monkeypatch):
+ """LM_STUDIO_BASE_URL env var is loaded into settings."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("LM_STUDIO_BASE_URL", "http://custom:5678/v1")
+ settings = Settings()
+ assert settings.lm_studio_base_url == "http://custom:5678/v1"
+
+ def test_provider_rate_limit_from_env(self, monkeypatch):
+ """PROVIDER_RATE_LIMIT env var is loaded into settings."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("PROVIDER_RATE_LIMIT", "20")
+ settings = Settings()
+ assert settings.provider_rate_limit == 20
+
+ def test_provider_rate_window_from_env(self, monkeypatch):
+ """PROVIDER_RATE_WINDOW env var is loaded into settings."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("PROVIDER_RATE_WINDOW", "30")
+ settings = Settings()
+ assert settings.provider_rate_window == 30
+
+ def test_http_read_timeout_from_env(self, monkeypatch):
+ """HTTP_READ_TIMEOUT env var is loaded into settings."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("HTTP_READ_TIMEOUT", "600")
+ settings = Settings()
+ assert settings.http_read_timeout == 600.0
+
+ def test_http_write_timeout_from_env(self, monkeypatch):
+ """HTTP_WRITE_TIMEOUT env var is loaded into settings."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("HTTP_WRITE_TIMEOUT", "20")
+ settings = Settings()
+ assert settings.http_write_timeout == 20.0
+
+ def test_http_connect_timeout_from_env(self, monkeypatch):
+ """HTTP_CONNECT_TIMEOUT env var is loaded into settings."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("HTTP_CONNECT_TIMEOUT", "5")
+ settings = Settings()
+ assert settings.http_connect_timeout == 5.0
+
+
+# --- NimSettings Validation Tests ---
+class TestNimSettingsValidBounds:
+ """Test that valid values within bounds are accepted."""
+
+ @pytest.mark.parametrize("top_k", [-1, 0, 1, 100])
+ def test_top_k_valid(self, top_k):
+ """top_k >= -1 should be accepted."""
+ s = NimSettings(top_k=top_k)
+ assert s.top_k == top_k
+
+ @pytest.mark.parametrize("temp", [0.0, 0.5, 1.0, 2.0])
+ def test_temperature_valid(self, temp):
+ s = NimSettings(temperature=temp)
+ assert s.temperature == temp
+
+ @pytest.mark.parametrize("top_p", [0.0, 0.5, 1.0])
+ def test_top_p_valid(self, top_p):
+ s = NimSettings(top_p=top_p)
+ assert s.top_p == top_p
+
+ def test_max_tokens_valid(self):
+ s = NimSettings(max_tokens=1)
+ assert s.max_tokens == 1
+
+ def test_min_tokens_valid(self):
+ s = NimSettings(min_tokens=0)
+ assert s.min_tokens == 0
+
+ @pytest.mark.parametrize("penalty", [-2.0, 0.0, 2.0])
+ def test_presence_penalty_valid(self, penalty):
+ s = NimSettings(presence_penalty=penalty)
+ assert s.presence_penalty == penalty
+
+ @pytest.mark.parametrize("penalty", [-2.0, 0.0, 2.0])
+ def test_frequency_penalty_valid(self, penalty):
+ s = NimSettings(frequency_penalty=penalty)
+ assert s.frequency_penalty == penalty
+
+ @pytest.mark.parametrize("min_p", [0.0, 0.5, 1.0])
+ def test_min_p_valid(self, min_p):
+ s = NimSettings(min_p=min_p)
+ assert s.min_p == min_p
+
+
+class TestNimSettingsInvalidBounds:
+ """Test that out-of-range values raise ValidationError."""
+
+ @pytest.mark.parametrize("top_k", [-2, -100])
+ def test_top_k_below_lower_bound(self, top_k):
+ with pytest.raises((ValidationError, ValueError)):
+ NimSettings(top_k=top_k)
+
+ def test_temperature_negative(self):
+ with pytest.raises(ValidationError):
+ NimSettings(temperature=-0.1)
+
+ @pytest.mark.parametrize("top_p", [-0.1, 1.1])
+ def test_top_p_out_of_range(self, top_p):
+ with pytest.raises(ValidationError):
+ NimSettings(top_p=top_p)
+
+ @pytest.mark.parametrize("penalty", [-2.1, 2.1])
+ def test_presence_penalty_out_of_range(self, penalty):
+ with pytest.raises(ValidationError):
+ NimSettings(presence_penalty=penalty)
+
+ @pytest.mark.parametrize("penalty", [-2.1, 2.1])
+ def test_frequency_penalty_out_of_range(self, penalty):
+ with pytest.raises(ValidationError):
+ NimSettings(frequency_penalty=penalty)
+
+ @pytest.mark.parametrize("min_p", [-0.1, 1.1])
+ def test_min_p_out_of_range(self, min_p):
+ with pytest.raises(ValidationError):
+ NimSettings(min_p=min_p)
+
+ @pytest.mark.parametrize("max_tokens", [0, -1])
+ def test_max_tokens_too_low(self, max_tokens):
+ with pytest.raises(ValidationError):
+ NimSettings(max_tokens=max_tokens)
+
+ def test_min_tokens_negative(self):
+ with pytest.raises(ValidationError):
+ NimSettings(min_tokens=-1)
+
+
+class TestNimSettingsValidators:
+ """Test custom field validators in NimSettings."""
+
+ @pytest.mark.parametrize(
+ "seed_val,expected",
+ [("", None), (None, None), ("42", 42), (42, 42)],
+ ids=["empty_str", "none", "str_42", "int_42"],
+ )
+ def test_parse_optional_int(self, seed_val, expected):
+ s = NimSettings(seed=seed_val)
+ assert s.seed == expected
+
+ @pytest.mark.parametrize(
+ "stop_val,expected",
+ [("", None), ("STOP", "STOP"), (None, None)],
+ ids=["empty_str", "valid", "none"],
+ )
+ def test_parse_optional_str_stop(self, stop_val, expected):
+ s = NimSettings(stop=stop_val)
+ assert s.stop == expected
+
+ @pytest.mark.parametrize(
+ "chat_template_val,expected",
+ [("", None), ("template", "template")],
+ ids=["empty_str", "valid"],
+ )
+ def test_parse_optional_str_chat_template(self, chat_template_val, expected):
+ s = NimSettings(chat_template=chat_template_val)
+ assert s.chat_template == expected
+
+ def test_extra_forbid_rejects_unknown_field(self):
+ """NimSettings with extra='forbid' rejects unknown fields."""
+ from typing import Any, cast
+
+ with pytest.raises(ValidationError):
+ NimSettings(**cast(Any, {"unknown_field": "value"}))
+
+
+class TestSettingsOptionalStr:
+ """Test Settings parse_optional_str validator."""
+
+ def test_empty_telegram_token_to_none(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "")
+ s = Settings()
+ assert s.telegram_bot_token is None
+
+ def test_valid_telegram_token_preserved(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc123")
+ s = Settings()
+ assert s.telegram_bot_token == "abc123"
+
+ def test_empty_allowed_user_id_to_none(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("ALLOWED_TELEGRAM_USER_ID", "")
+ s = Settings()
+ assert s.allowed_telegram_user_id is None
+
+ def test_discord_bot_token_from_env(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("DISCORD_BOT_TOKEN", "discord_token_123")
+ s = Settings()
+ assert s.discord_bot_token == "discord_token_123"
+
+ def test_empty_discord_bot_token_to_none(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("DISCORD_BOT_TOKEN", "")
+ s = Settings()
+ assert s.discord_bot_token is None
+
+ def test_allowed_discord_channels_from_env(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("ALLOWED_DISCORD_CHANNELS", "111,222,333")
+ s = Settings()
+ assert s.allowed_discord_channels == "111,222,333"
+
+ def test_messaging_platform_from_env(self, monkeypatch):
+ from config.settings import Settings
+
+ monkeypatch.setenv("MESSAGING_PLATFORM", "discord")
+ s = Settings()
+ assert s.messaging_platform == "discord"
+
+ def test_whisper_device_auto_rejected(self, monkeypatch):
+ """WHISPER_DEVICE=auto raises ValidationError (auto removed)."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("WHISPER_DEVICE", "auto")
+ with pytest.raises(ValidationError, match="whisper_device"):
+ Settings()
+
+ @pytest.mark.parametrize("device", ["cpu", "cuda"])
+ def test_whisper_device_valid(self, monkeypatch, device):
+ """Valid whisper_device values are accepted."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("WHISPER_DEVICE", device)
+ s = Settings()
+ assert s.whisper_device == device
+
+
+class TestPerModelMapping:
+ """Test per-model fields and resolve_model()."""
+
+ def test_model_fields_default_none(self):
+ """Per-model fields default to None."""
+ from config.settings import Settings
+
+ s = Settings()
+ assert s.model_opus is None
+ assert s.model_sonnet is None
+ assert s.model_haiku is None
+
+ def test_model_opus_from_env(self, monkeypatch):
+ """MODEL_OPUS env var is loaded."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("MODEL_OPUS", "open_router/deepseek/deepseek-r1")
+ s = Settings()
+ assert s.model_opus == "open_router/deepseek/deepseek-r1"
+
+ @pytest.mark.parametrize(
+ "env_vars,expected_model,expected_haiku",
+ [
+ (
+ {"MODEL": "nvidia_nim/meta/llama3-70b-instruct"},
+ "nvidia_nim/meta/llama3-70b-instruct",
+ None,
+ ),
+ (
+ {
+ "MODEL": "open_router/anthropic/claude-3-opus",
+ "MODEL_HAIKU": "open_router/anthropic/claude-3-haiku",
+ },
+ "open_router/anthropic/claude-3-opus",
+ "open_router/anthropic/claude-3-haiku",
+ ),
+ ({"MODEL": "lmstudio/qwen2.5-7b"}, "lmstudio/qwen2.5-7b", None),
+ ({"MODEL": "llamacpp/local-model"}, "llamacpp/local-model", None),
+ ],
+ )
+ def test_settings_models_from_env(
+ self, env_vars, expected_model, expected_haiku, monkeypatch
+ ):
+ """Test environment variables override model defaults."""
+ from config.settings import Settings
+
+ for k, v in env_vars.items():
+ monkeypatch.setenv(k, v)
+
+ s = Settings()
+ assert s.model == expected_model
+ assert s.model_haiku == expected_haiku
+
+ def test_model_sonnet_from_env(self, monkeypatch):
+ """MODEL_SONNET env var is loaded."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("MODEL_SONNET", "nvidia_nim/meta/llama-3.3-70b-instruct")
+ s = Settings()
+ assert s.model_sonnet == "nvidia_nim/meta/llama-3.3-70b-instruct"
+
+ def test_model_haiku_from_env(self, monkeypatch):
+ """MODEL_HAIKU env var is loaded."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("MODEL_HAIKU", "lmstudio/qwen2.5-7b")
+ s = Settings()
+ assert s.model_haiku == "lmstudio/qwen2.5-7b"
+
+ def test_model_opus_invalid_provider_raises(self, monkeypatch):
+ """MODEL_OPUS with invalid provider prefix raises ValidationError."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("MODEL_OPUS", "bad_provider/some-model")
+ with pytest.raises(ValidationError, match="Invalid provider"):
+ Settings()
+
+ def test_model_opus_no_slash_raises(self, monkeypatch):
+ """MODEL_OPUS without provider prefix raises ValidationError."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("MODEL_OPUS", "noprefix")
+ with pytest.raises(ValidationError, match="provider type"):
+ Settings()
+
+ def test_model_haiku_invalid_provider_raises(self, monkeypatch):
+ """MODEL_HAIKU with invalid provider prefix raises ValidationError."""
+ from config.settings import Settings
+
+ monkeypatch.setenv("MODEL_HAIKU", "invalid/model")
+ with pytest.raises(ValidationError, match="Invalid provider"):
+ Settings()
+
+ def test_resolve_model_opus_override(self):
+ """resolve_model returns model_opus for opus model names."""
+ from config.settings import Settings
+
+ s = Settings()
+ s.model_opus = "open_router/deepseek/deepseek-r1"
+ assert (
+ s.resolve_model("claude-opus-4-20250514")
+ == "open_router/deepseek/deepseek-r1"
+ )
+ assert s.resolve_model("claude-3-opus") == "open_router/deepseek/deepseek-r1"
+ assert (
+ s.resolve_model("claude-3-opus-20240229")
+ == "open_router/deepseek/deepseek-r1"
+ )
+
+ def test_resolve_model_sonnet_override(self):
+ """resolve_model returns model_sonnet for sonnet model names."""
+ from config.settings import Settings
+
+ s = Settings()
+ s.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct"
+ assert (
+ s.resolve_model("claude-sonnet-4-20250514")
+ == "nvidia_nim/meta/llama-3.3-70b-instruct"
+ )
+ assert (
+ s.resolve_model("claude-3-5-sonnet-20241022")
+ == "nvidia_nim/meta/llama-3.3-70b-instruct"
+ )
+
+ def test_resolve_model_haiku_override(self):
+ """resolve_model returns model_haiku for haiku model names."""
+ from config.settings import Settings
+
+ s = Settings()
+ s.model_haiku = "lmstudio/qwen2.5-7b"
+ assert s.resolve_model("claude-3-haiku-20240307") == "lmstudio/qwen2.5-7b"
+ assert s.resolve_model("claude-3-5-haiku-20241022") == "lmstudio/qwen2.5-7b"
+ assert s.resolve_model("claude-haiku-4-20250514") == "lmstudio/qwen2.5-7b"
+
+ def test_resolve_model_fallback_when_override_not_set(self):
+ """resolve_model falls back to MODEL when model override is None."""
+ from config.settings import Settings
+
+ s = Settings()
+ s.model = "nvidia_nim/fallback-model"
+ # No model overrides set
+ assert s.resolve_model("claude-opus-4-20250514") == "nvidia_nim/fallback-model"
+ assert (
+ s.resolve_model("claude-sonnet-4-20250514") == "nvidia_nim/fallback-model"
+ )
+ assert s.resolve_model("claude-3-haiku-20240307") == "nvidia_nim/fallback-model"
+
+ def test_resolve_model_unknown_model_falls_back(self):
+ """resolve_model falls back to MODEL for unrecognized model names."""
+ from config.settings import Settings
+
+ s = Settings()
+ s.model = "nvidia_nim/fallback-model"
+ s.model_opus = "open_router/opus-model"
+ assert s.resolve_model("claude-2.1") == "nvidia_nim/fallback-model"
+ assert s.resolve_model("some-unknown-model") == "nvidia_nim/fallback-model"
+
+ def test_resolve_model_case_insensitive(self):
+ """Model classification is case-insensitive."""
+ from config.settings import Settings
+
+ s = Settings()
+ s.model_opus = "open_router/opus-model"
+ assert s.resolve_model("Claude-OPUS-4") == "open_router/opus-model"
+
+ def test_parse_provider_type(self):
+ """parse_provider_type extracts provider from model string."""
+ from config.settings import Settings
+
+ assert Settings.parse_provider_type("nvidia_nim/meta/llama") == "nvidia_nim"
+ assert Settings.parse_provider_type("open_router/deepseek/r1") == "open_router"
+ assert Settings.parse_provider_type("lmstudio/qwen") == "lmstudio"
+ assert Settings.parse_provider_type("llamacpp/model") == "llamacpp"
+
+ def test_parse_model_name(self):
+ """parse_model_name extracts model name from model string."""
+ from config.settings import Settings
+
+ assert Settings.parse_model_name("nvidia_nim/meta/llama") == "meta/llama"
+ assert Settings.parse_model_name("lmstudio/qwen") == "qwen"
+ assert Settings.parse_model_name("llamacpp/model") == "model"
diff --git a/Claude_Code/tests/config/test_logging_config.py b/Claude_Code/tests/config/test_logging_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..11a818cc23717ea9d4c4ad01c4bf9c77fd71f019
--- /dev/null
+++ b/Claude_Code/tests/config/test_logging_config.py
@@ -0,0 +1,59 @@
+"""Tests for config/logging_config.py."""
+
+import json
+import logging
+from pathlib import Path
+
+from config.logging_config import configure_logging
+
+
+def test_configure_logging_writes_json_to_file(tmp_path):
+ """configure_logging writes JSON lines to the specified file."""
+ log_file = str(tmp_path / "test.log")
+ configure_logging(log_file, force=True)
+
+ # Emit a log via stdlib (intercepted to loguru)
+ logger = logging.getLogger("test.module")
+ logger.info("Test message for JSON")
+
+ # Force flush - loguru may buffer
+ from loguru import logger as loguru_logger
+
+ loguru_logger.complete()
+
+ content = Path(log_file).read_text(encoding="utf-8")
+ lines = [line for line in content.strip().split("\n") if line]
+ assert len(lines) >= 1
+
+ # Each line should be valid JSON
+ for line in lines:
+ record = json.loads(line)
+ assert "text" in record or "message" in record or "record" in record
+
+
+def test_configure_logging_idempotent(tmp_path):
+ """configure_logging is idempotent - safe to call twice with force."""
+ log_file = str(tmp_path / "test.log")
+ configure_logging(log_file, force=True)
+ configure_logging(log_file, force=True) # Should not raise
+
+ logger = logging.getLogger("test.idempotent")
+ logger.info("After second configure")
+
+
+def test_configure_logging_skips_when_already_configured(tmp_path):
+ """Without force, second call is a no-op (avoids reconfig on hot reload)."""
+ log_file = str(tmp_path / "test.log")
+ configure_logging(log_file, force=True)
+ # Second call without force - should skip; no exception, log file unchanged
+ configure_logging(str(tmp_path / "other.log"), force=False)
+ # Logs still go to first file
+ logger = logging.getLogger("test.skip")
+ logger.info("Still goes to first file")
+ from loguru import logger as loguru_logger
+
+ loguru_logger.complete()
+ assert (tmp_path / "test.log").exists()
+ assert "Still goes to first file" in (tmp_path / "test.log").read_text(
+ encoding="utf-8"
+ )
diff --git a/Claude_Code/tests/conftest.py b/Claude_Code/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0928f5955a172b787fe8e9cd2b33fce62cf1fa2
--- /dev/null
+++ b/Claude_Code/tests/conftest.py
@@ -0,0 +1,195 @@
+import asyncio
+import contextlib
+import logging
+import os
+
+import pytest
+
+# Set mock environment BEFORE any imports that use Settings
+os.environ.setdefault("NVIDIA_NIM_API_KEY", "test_key")
+os.environ.setdefault("MODEL", "nvidia_nim/test-model")
+os.environ["PTB_TIMEDELTA"] = "1"
+# Ensure tests don't pick up a server API key from the repo .env
+# (tests expect endpoints to be unauthenticated by default)
+os.environ["ANTHROPIC_AUTH_TOKEN"] = ""
+
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+from config.nim import NimSettings
+from messaging.models import IncomingMessage
+from messaging.platforms.base import (
+ CLISession,
+ MessagingPlatform,
+ SessionManagerInterface,
+)
+from messaging.session import SessionStore
+from providers.base import ProviderConfig
+from providers.nvidia_nim import NvidiaNimProvider
+
+
+@pytest.fixture(autouse=True)
+def _isolate_from_dotenv(monkeypatch):
+ """Prevent Pydantic BaseSettings from reading the .env file during tests."""
+ from config.settings import Settings
+
+ monkeypatch.setattr(
+ Settings, "model_config", {**Settings.model_config, "env_file": None}
+ )
+
+
+@pytest.fixture
+def provider_config():
+ return ProviderConfig(
+ api_key="test_key",
+ base_url="https://test.api.nvidia.com/v1",
+ rate_limit=10,
+ rate_window=60,
+ )
+
+
+@pytest.fixture
+def nim_provider(provider_config):
+ return NvidiaNimProvider(provider_config, nim_settings=NimSettings())
+
+
+@pytest.fixture
+def open_router_provider(provider_config):
+ from providers.open_router import OpenRouterProvider
+
+ return OpenRouterProvider(provider_config)
+
+
+@pytest.fixture
+def lmstudio_provider(provider_config):
+ from providers.lmstudio import LMStudioProvider
+
+ lmstudio_config = ProviderConfig(
+ api_key="lm-studio",
+ base_url="http://localhost:1234/v1",
+ rate_limit=provider_config.rate_limit,
+ rate_window=provider_config.rate_window,
+ )
+ return LMStudioProvider(lmstudio_config)
+
+
+@pytest.fixture
+def llamacpp_provider(provider_config):
+ from providers.llamacpp import LlamaCppProvider
+
+ llamacpp_config = ProviderConfig(
+ api_key="llamacpp",
+ base_url="http://localhost:8080/v1",
+ rate_limit=10,
+ rate_window=60,
+ )
+ return LlamaCppProvider(llamacpp_config)
+
+
+@pytest.fixture
+def mock_cli_session():
+ session = MagicMock(spec=CLISession)
+ session.start_task = MagicMock() # This will return an async generator
+ session.is_busy = False
+ return session
+
+
+@pytest.fixture
+def mock_cli_manager():
+ manager = MagicMock(spec=SessionManagerInterface)
+ manager.get_or_create_session = AsyncMock()
+ manager.register_real_session_id = AsyncMock(return_value=True)
+ manager.stop_all = AsyncMock()
+ manager.remove_session = AsyncMock(return_value=True)
+ manager.get_stats = MagicMock(return_value={"active_sessions": 0})
+ return manager
+
+
+@pytest.fixture
+def mock_platform():
+ platform = MagicMock(spec=MessagingPlatform)
+ platform.send_message = AsyncMock(return_value="msg_123")
+ platform.edit_message = AsyncMock()
+ platform.delete_message = AsyncMock()
+ platform.queue_send_message = AsyncMock(return_value="msg_123")
+ platform.queue_edit_message = AsyncMock()
+ platform.queue_delete_message = AsyncMock()
+
+ def _fire_and_forget(task):
+ if asyncio.iscoroutine(task):
+ # Create a task to avoid "coroutine was never awaited" warning
+ return asyncio.create_task(task)
+ return None
+
+ platform.fire_and_forget = MagicMock(side_effect=_fire_and_forget)
+ return platform
+
+
+@pytest.fixture
+def mock_session_store():
+ store = MagicMock(spec=SessionStore)
+ store.save_tree = MagicMock()
+ store.get_tree = MagicMock(return_value=None)
+ store.register_node = MagicMock()
+ store.clear_all = MagicMock()
+ store.record_message_id = MagicMock()
+ store.get_message_ids_for_chat = MagicMock(return_value=[])
+ return store
+
+
+@pytest.fixture
+def incoming_message_factory():
+ _valid_keys = frozenset(
+ {
+ "text",
+ "chat_id",
+ "user_id",
+ "message_id",
+ "platform",
+ "reply_to_message_id",
+ "message_thread_id",
+ "username",
+ "timestamp",
+ "raw_event",
+ "status_message_id",
+ }
+ )
+
+ def _create(**kwargs):
+ defaults: dict[str, Any] = {
+ "text": "hello",
+ "chat_id": "chat_1",
+ "user_id": "user_1",
+ "message_id": "msg_1",
+ "platform": "telegram",
+ }
+ defaults.update(kwargs)
+ if "timestamp" in defaults and isinstance(defaults["timestamp"], str):
+ from datetime import datetime
+
+ defaults["timestamp"] = datetime.fromisoformat(defaults["timestamp"])
+ filtered = {k: v for k, v in defaults.items() if k in _valid_keys}
+ return IncomingMessage(**filtered)
+
+ return _create
+
+
+@pytest.fixture(autouse=True)
+def _propagate_loguru_to_caplog():
+ """Route loguru logs to stdlib logging so pytest caplog captures them."""
+ from loguru import logger as loguru_logger
+
+ class _PropagateHandler:
+ def write(self, message):
+ record = message.record
+ level = record["level"].no
+ stdlib_level = min(level, logging.CRITICAL)
+ py_logger = logging.getLogger(record["name"])
+ py_logger.log(stdlib_level, record["message"])
+
+ handler_id = loguru_logger.add(_PropagateHandler(), format="{message}")
+ yield
+ with contextlib.suppress(ValueError):
+ loguru_logger.remove(
+ handler_id
+ ) # Handler already removed (e.g. by test_logging_config)
diff --git a/Claude_Code/tests/messaging/test_discord_markdown.py b/Claude_Code/tests/messaging/test_discord_markdown.py
new file mode 100644
index 0000000000000000000000000000000000000000..f20b56b18d5fc428a7915a880c777d076765f758
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_discord_markdown.py
@@ -0,0 +1,201 @@
+"""Tests for messaging/rendering/discord_markdown.py."""
+
+from messaging.rendering.discord_markdown import (
+ _is_gfm_table_header_line,
+ _normalize_gfm_tables,
+ discord_bold,
+ discord_code_inline,
+ escape_discord,
+ escape_discord_code,
+ format_status,
+ format_status_discord,
+ render_markdown_to_discord,
+)
+
+
+class TestEscapeDiscord:
+ """Tests for escape_discord."""
+
+ def test_empty_string(self):
+ assert escape_discord("") == ""
+
+ def test_plain_text_unchanged(self):
+ assert escape_discord("hello world") == "hello world"
+
+ def test_special_chars_escaped(self):
+ for ch in "\\*_`~|>":
+ assert escape_discord(ch) == f"\\{ch}"
+
+ def test_mixed_special_and_plain(self):
+ assert escape_discord("a*b_c") == "a\\*b\\_c"
+
+ def test_unicode_preserved(self):
+ assert escape_discord("café 日本語") == "café 日本語"
+
+
+class TestEscapeDiscordCode:
+ """Tests for escape_discord_code."""
+
+ def test_empty_string(self):
+ assert escape_discord_code("") == ""
+
+ def test_backslash_escaped(self):
+ assert escape_discord_code("\\") == "\\\\"
+
+ def test_backtick_escaped(self):
+ assert escape_discord_code("`") == "\\`"
+
+ def test_both_escaped(self):
+ assert escape_discord_code("`\\") == "\\`\\\\"
+
+
+class TestDiscordBold:
+ """Tests for discord_bold."""
+
+ def test_simple(self):
+ assert discord_bold("hello") == "**hello**"
+
+ def test_escapes_inner(self):
+ assert discord_bold("a*b") == "**a\\*b**"
+
+
+class TestDiscordCodeInline:
+ """Tests for discord_code_inline."""
+
+ def test_simple(self):
+ assert discord_code_inline("x") == "`x`"
+
+ def test_escapes_backtick(self):
+ assert discord_code_inline("`") == "`\\``"
+
+
+class TestFormatStatusDiscord:
+ """Tests for format_status_discord."""
+
+ def test_label_only(self):
+ assert format_status_discord("Running") == "**Running**"
+
+ def test_label_with_suffix(self):
+ # Parentheses not in DISCORD_SPECIAL, so unchanged
+ assert (
+ format_status_discord("Queued", "(position 2)") == "**Queued** (position 2)"
+ )
+
+
+class TestFormatStatus:
+ """Tests for format_status."""
+
+ def test_label_only(self):
+ assert format_status("🔄", "Running") == "🔄 **Running**"
+
+ def test_label_with_suffix(self):
+ assert format_status("⏳", "Waiting", "5/10") == "⏳ **Waiting** 5/10"
+
+
+class TestIsGfmTableHeaderLine:
+ """Tests for _is_gfm_table_header_line."""
+
+ def test_no_pipe_returns_false(self):
+ assert _is_gfm_table_header_line("hello world") is False
+
+ def test_separator_only_returns_false(self):
+ assert _is_gfm_table_header_line("|---|") is False
+ assert _is_gfm_table_header_line("|:---|:---|") is False
+
+ def test_valid_header(self):
+ assert _is_gfm_table_header_line("| A | B |") is True
+ assert _is_gfm_table_header_line("A | B") is True
+
+ def test_single_column_returns_false(self):
+ assert _is_gfm_table_header_line("| A |") is False
+
+
+class TestNormalizeGfmTables:
+ """Tests for _normalize_gfm_tables."""
+
+ def test_single_line_unchanged(self):
+ assert _normalize_gfm_tables("hello") == "hello"
+
+ def test_two_lines_no_table_unchanged(self):
+ assert _normalize_gfm_tables("a\nb") == "a\nb"
+
+ def test_table_gets_blank_line_before(self):
+ text = "para\n| A | B |\n|---|\n| 1 | 2 |"
+ result = _normalize_gfm_tables(text)
+ assert "para" in result
+ assert "| A | B |" in result
+
+ def test_table_inside_fence_unchanged(self):
+ text = "```\n| A | B |\n|---|\n```"
+ result = _normalize_gfm_tables(text)
+ assert result == text
+
+
+class TestRenderMarkdownToDiscord:
+ """Tests for render_markdown_to_discord."""
+
+ def test_empty_string(self):
+ assert render_markdown_to_discord("") == ""
+
+ def test_plain_paragraph(self):
+ assert "hello" in render_markdown_to_discord("hello")
+
+ def test_headings(self):
+ result = render_markdown_to_discord("# Title\n## Sub")
+ assert "Title" in result
+ assert "Sub" in result
+
+ def test_bold_italic(self):
+ result = render_markdown_to_discord("**bold** *italic*")
+ assert "bold" in result
+ assert "italic" in result
+
+ def test_strikethrough(self):
+ result = render_markdown_to_discord("~~strike~~")
+ assert "strike" in result
+
+ def test_inline_code(self):
+ result = render_markdown_to_discord("use `code` here")
+ assert "`" in result
+ assert "code" in result
+
+ def test_code_block(self):
+ result = render_markdown_to_discord("```\nprint(1)\n```")
+ assert "print(1)" in result
+ assert "```" in result
+
+ def test_blockquote(self):
+ result = render_markdown_to_discord("> quote")
+ assert "quote" in result
+
+ def test_bullet_list(self):
+ result = render_markdown_to_discord("- a\n- b")
+ assert "a" in result
+ assert "b" in result
+
+ def test_ordered_list(self):
+ result = render_markdown_to_discord("1. first\n2. second")
+ assert "first" in result
+ assert "second" in result
+
+ def test_link(self):
+ result = render_markdown_to_discord("[text](https://example.com)")
+ assert "text" in result
+ assert "https://example.com" in result
+
+ def test_image_with_alt(self):
+ result = render_markdown_to_discord("")
+ assert "alt" in result
+ assert "https://img.png" in result
+
+ def test_image_without_alt(self):
+ result = render_markdown_to_discord("")
+ assert "https://img.png" in result
+
+ def test_gfm_table(self):
+ text = "| A | B |\n|---|---|\n| 1 | 2 |"
+ result = render_markdown_to_discord(text)
+ assert "A" in result
+ assert "B" in result
+ assert "1" in result
+ assert "2" in result
diff --git a/Claude_Code/tests/messaging/test_discord_platform.py b/Claude_Code/tests/messaging/test_discord_platform.py
new file mode 100644
index 0000000000000000000000000000000000000000..56e820156a06799eefc3885d159c1ce886618e46
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_discord_platform.py
@@ -0,0 +1,380 @@
+"""Tests for Discord platform adapter."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from messaging.platforms.discord import (
+ DISCORD_AVAILABLE,
+ DiscordPlatform,
+ _get_discord,
+ _parse_allowed_channels,
+)
+
+
+class TestGetDiscord:
+ """Tests for _get_discord helper."""
+
+ def test_raises_when_discord_not_available(self):
+ import messaging.platforms.discord as discord_mod
+
+ with (
+ patch.object(discord_mod, "DISCORD_AVAILABLE", False),
+ patch.object(discord_mod, "_discord_module", None),
+ pytest.raises(ImportError, match=r"discord\.py is required"),
+ ):
+ _get_discord()
+
+
+class TestParseAllowedChannels:
+ """Tests for _parse_allowed_channels helper."""
+
+ def test_empty_string_returns_empty_set(self):
+ assert _parse_allowed_channels("") == set()
+ assert _parse_allowed_channels(None) == set()
+
+ def test_whitespace_only_returns_empty_set(self):
+ assert _parse_allowed_channels(" ") == set()
+
+ def test_single_channel(self):
+ assert _parse_allowed_channels("123456789") == {"123456789"}
+
+ def test_comma_separated(self):
+ assert _parse_allowed_channels("111,222,333") == {"111", "222", "333"}
+
+ def test_strips_whitespace(self):
+ assert _parse_allowed_channels(" 111 , 222 ") == {"111", "222"}
+
+ def test_empty_parts_ignored(self):
+ assert _parse_allowed_channels("111,,222,") == {"111", "222"}
+
+
+@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed")
+class TestDiscordPlatform:
+ """Tests for DiscordPlatform (requires discord.py)."""
+
+ def test_init_with_token(self):
+ platform = DiscordPlatform(
+ bot_token="test_token",
+ allowed_channel_ids="123,456",
+ )
+ assert platform.bot_token == "test_token"
+ assert platform.allowed_channel_ids == {"123", "456"}
+
+ def test_init_without_allowed_channels(self):
+ with patch.dict("os.environ", {"ALLOWED_DISCORD_CHANNELS": ""}, clear=False):
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="")
+ assert platform.allowed_channel_ids == set()
+
+ def test_empty_allowed_channels_rejects_all_messages(self):
+ """When allowed_channel_ids is empty, no channels are allowed (secure default)."""
+ with patch.dict("os.environ", {"ALLOWED_DISCORD_CHANNELS": ""}, clear=False):
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="")
+ assert platform.allowed_channel_ids == set()
+ # Empty set means: not self.allowed_channel_ids is True -> reject
+
+ def test_truncate_long_message(self):
+ platform = DiscordPlatform(bot_token="token")
+ long_text = "x" * 2500
+ truncated = platform._truncate(long_text)
+ assert len(truncated) == 2000
+ assert truncated.endswith("...")
+
+ def test_truncate_short_message_unchanged(self):
+ platform = DiscordPlatform(bot_token="token")
+ short = "hello"
+ assert platform._truncate(short) == short
+
+ def test_truncate_exactly_at_limit_unchanged(self):
+ platform = DiscordPlatform(bot_token="token")
+ exact = "x" * 2000
+ assert platform._truncate(exact) == exact
+
+ def test_truncate_one_over_limit_truncates(self):
+ platform = DiscordPlatform(bot_token="token")
+ over = "x" * 2001
+ result = platform._truncate(over)
+ assert len(result) == 2000
+ assert result.endswith("...")
+
+ def test_truncate_empty_string(self):
+ platform = DiscordPlatform(bot_token="token")
+ assert platform._truncate("") == ""
+
+ @pytest.mark.asyncio
+ async def test_send_message_returns_message_id(self):
+ platform = DiscordPlatform(bot_token="token")
+ mock_msg = MagicMock()
+ mock_msg.id = 999
+ mock_channel = AsyncMock()
+ mock_channel.send = AsyncMock(return_value=mock_msg)
+ platform._connected = True
+ with patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ):
+ msg_id = await platform.send_message("123", "Hello")
+ assert msg_id == "999"
+
+ @pytest.mark.asyncio
+ async def test_edit_message(self):
+ platform = DiscordPlatform(bot_token="token")
+ mock_msg = AsyncMock()
+ mock_channel = AsyncMock()
+ mock_channel.fetch_message = AsyncMock(return_value=mock_msg)
+ platform._connected = True
+ with patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ):
+ await platform.edit_message("123", "456", "Updated text")
+ mock_msg.edit.assert_called_once_with(content="Updated text")
+
+ @pytest.mark.asyncio
+ async def test_send_message_channel_not_found_raises(self):
+ platform = DiscordPlatform(bot_token="token")
+ platform._connected = True
+ with (
+ patch.object(platform._client, "get_channel", MagicMock(return_value=None)),
+ pytest.raises(RuntimeError, match="Channel"),
+ ):
+ await platform.send_message("123", "Hello")
+
+ @pytest.mark.asyncio
+ async def test_send_message_channel_no_send_raises(self):
+ platform = DiscordPlatform(bot_token="token")
+ platform._connected = True
+ mock_channel = MagicMock(spec=[]) # No send attr
+ with (
+ patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ),
+ pytest.raises(RuntimeError, match="Channel"),
+ ):
+ await platform.send_message("123", "Hello")
+
+ @pytest.mark.asyncio
+ async def test_queue_send_message_without_limiter_calls_send_message(self):
+ platform = DiscordPlatform(bot_token="token")
+ platform._limiter = None
+ platform._connected = True
+ mock_channel = AsyncMock()
+ mock_msg = MagicMock()
+ mock_msg.id = 42
+ mock_channel.send = AsyncMock(return_value=mock_msg)
+ with patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ):
+ result = await platform.queue_send_message("123", "hi")
+ assert result == "42"
+ mock_channel.send.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_queue_edit_message_without_limiter_calls_edit_message(self):
+ platform = DiscordPlatform(bot_token="token")
+ platform._limiter = None
+ platform._connected = True
+ mock_msg = AsyncMock()
+ mock_channel = AsyncMock()
+ mock_channel.fetch_message = AsyncMock(return_value=mock_msg)
+ with patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ):
+ await platform.queue_edit_message("123", "456", "Updated")
+ mock_msg.edit.assert_called_once_with(content="Updated")
+
+ @pytest.mark.asyncio
+ async def test_on_discord_message_bot_ignored(self):
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123")
+ handler = AsyncMock()
+ platform.on_message(handler)
+ msg = MagicMock()
+ msg.author.bot = True
+ msg.content = "hello"
+ msg.channel.id = 123
+ await platform._on_discord_message(msg)
+ handler.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_on_discord_message_empty_content_ignored(self):
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123")
+ handler = AsyncMock()
+ platform.on_message(handler)
+ msg = MagicMock()
+ msg.author.bot = False
+ msg.content = ""
+ msg.channel.id = 123
+ await platform._on_discord_message(msg)
+ handler.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_on_discord_message_channel_not_allowed_ignored(self):
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123")
+ handler = AsyncMock()
+ platform.on_message(handler)
+ msg = MagicMock()
+ msg.author.bot = False
+ msg.content = "hello"
+ msg.channel.id = 999
+ await platform._on_discord_message(msg)
+ handler.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_on_discord_message_valid_calls_handler(self):
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123")
+ handler = AsyncMock()
+ platform.on_message(handler)
+ msg = MagicMock()
+ msg.author.bot = False
+ msg.author.id = 456
+ msg.author.display_name = "User"
+ msg.content = "hello"
+ msg.channel.id = 123
+ msg.id = 789
+ msg.reference = None
+ await platform._on_discord_message(msg)
+ handler.assert_awaited_once()
+ call = handler.call_args[0][0]
+ assert call.text == "hello"
+ assert call.chat_id == "123"
+ assert call.user_id == "456"
+ assert call.message_id == "789"
+ assert call.platform == "discord"
+
+ @pytest.mark.asyncio
+ async def test_send_message_with_reply_to(self):
+ platform = DiscordPlatform(bot_token="token")
+ mock_msg = MagicMock()
+ mock_msg.id = 999
+ mock_channel = AsyncMock()
+ mock_channel.send = AsyncMock(return_value=mock_msg)
+ platform._connected = True
+ with (
+ patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ),
+ patch("messaging.platforms.discord._get_discord") as mock_get,
+ ):
+ mock_discord = MagicMock()
+ mock_get.return_value = mock_discord
+ msg_id = await platform.send_message("123", "Hello", reply_to="456")
+ assert msg_id == "999"
+ mock_channel.send.assert_awaited_once()
+ call_kw = mock_channel.send.call_args[1]
+ assert call_kw.get("reference") is not None
+
+ @pytest.mark.asyncio
+ async def test_edit_message_not_found_returns_gracefully(self):
+ import discord as discord_pkg
+
+ platform = DiscordPlatform(bot_token="token")
+ mock_channel = AsyncMock()
+ mock_resp = MagicMock()
+ mock_resp.status = 404
+ mock_channel.fetch_message = AsyncMock(
+ side_effect=discord_pkg.NotFound(mock_resp, "Not found")
+ )
+ platform._connected = True
+ with patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ):
+ await platform.edit_message("123", "456", "Updated")
+ # Should not raise - NotFound is caught and we return
+
+ @pytest.mark.asyncio
+ async def test_delete_message(self):
+ platform = DiscordPlatform(bot_token="token")
+ mock_msg = AsyncMock()
+ mock_channel = AsyncMock()
+ mock_channel.fetch_message = AsyncMock(return_value=mock_msg)
+ platform._connected = True
+ with (
+ patch.object(
+ platform._client, "get_channel", MagicMock(return_value=mock_channel)
+ ),
+ patch("messaging.platforms.discord._get_discord") as mock_get,
+ ):
+ mock_get.return_value = MagicMock()
+ await platform.delete_message("123", "456")
+ mock_msg.delete.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_fire_and_forget_with_coroutine(self):
+ platform = DiscordPlatform(bot_token="token")
+
+ async def _task():
+ pass
+
+ coro = _task()
+ with patch("asyncio.create_task") as mock_create:
+
+ def _run(c):
+ return asyncio.ensure_future(c)
+
+ mock_create.side_effect = _run
+ platform.fire_and_forget(coro)
+ mock_create.assert_called_once()
+ await asyncio.sleep(0)
+
+ def test_on_message_registers_handler(self):
+ platform = DiscordPlatform(bot_token="token")
+ handler = AsyncMock()
+ platform.on_message(handler)
+ assert platform._message_handler is handler
+
+ @pytest.mark.asyncio
+ async def test_start_requires_token(self):
+ with patch.dict("os.environ", {"DISCORD_BOT_TOKEN": ""}, clear=False):
+ platform = DiscordPlatform(bot_token="")
+ with pytest.raises(ValueError, match="DISCORD_BOT_TOKEN"):
+ await platform.start()
+
+ @pytest.mark.asyncio
+ async def test_start_connects(self):
+ platform = DiscordPlatform(bot_token="token")
+
+ async def _fake_start(_token):
+ platform._connected = True
+
+ with (
+ patch.object(
+ platform._client,
+ "start",
+ new_callable=AsyncMock,
+ side_effect=_fake_start,
+ ),
+ patch(
+ "messaging.limiter.MessagingRateLimiter.get_instance",
+ new_callable=AsyncMock,
+ ),
+ ):
+ await platform.start()
+ assert platform.is_connected is True
+
+ @pytest.mark.asyncio
+ async def test_stop_when_already_closed(self):
+ platform = DiscordPlatform(bot_token="token")
+ platform._connected = True
+ with patch.object(
+ platform._client, "is_closed", new_callable=MagicMock, return_value=True
+ ):
+ await platform.stop()
+ assert platform.is_connected is False
+
+ @pytest.mark.asyncio
+ async def test_stop_closes_client(self):
+ platform = DiscordPlatform(bot_token="token")
+ platform._connected = True
+ mock_close = AsyncMock()
+ with (
+ patch.object(
+ platform._client,
+ "is_closed",
+ new_callable=MagicMock,
+ return_value=False,
+ ),
+ patch.object(platform._client, "close", mock_close),
+ ):
+ platform._start_task = None
+ await platform.stop()
+ mock_close.assert_awaited_once()
+ assert platform.is_connected is False
diff --git a/Claude_Code/tests/messaging/test_event_parser.py b/Claude_Code/tests/messaging/test_event_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..9bdadf1302a22f5bb17f3a48ee58c3e591f2f5c5
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_event_parser.py
@@ -0,0 +1,192 @@
+from messaging.event_parser import parse_cli_event
+
+
+def test_parse_cli_event_assistant_content():
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [
+ {"type": "thinking", "thinking": "Internal thought"},
+ {"type": "text", "text": "Hello user"},
+ ]
+ },
+ }
+ results = parse_cli_event(event)
+ assert len(results) == 2
+ assert results[0] == {"type": "thinking_chunk", "text": "Internal thought"}
+ assert results[1] == {"type": "text_chunk", "text": "Hello user"}
+
+
+def test_parse_cli_event_assistant_tools():
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [{"type": "tool_use", "name": "ls", "input": {"path": "."}}]
+ },
+ }
+ results = parse_cli_event(event)
+ assert len(results) == 1
+ assert results[0]["type"] == "tool_use"
+ assert results[0]["name"] == "ls"
+ assert results[0]["input"] == {"path": "."}
+
+
+def test_parse_cli_event_assistant_subagent():
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [
+ {
+ "type": "tool_use",
+ "name": "Task",
+ "input": {"description": "Fix bug"},
+ }
+ ]
+ },
+ }
+ results = parse_cli_event(event)
+ assert len(results) == 1
+ assert results[0]["type"] == "tool_use"
+ assert results[0]["name"] == "Task"
+ assert results[0]["input"] == {"description": "Fix bug"}
+
+
+def test_parse_cli_event_content_block_delta():
+ # Text delta
+ event_text = {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": " more"},
+ }
+ results_text = parse_cli_event(event_text)
+ assert results_text == [{"type": "text_delta", "index": 0, "text": " more"}]
+
+ # Thinking delta
+ event_think = {
+ "type": "content_block_delta",
+ "index": 1,
+ "delta": {"type": "thinking_delta", "thinking": " more thought"},
+ }
+ results_think = parse_cli_event(event_think)
+ assert results_think == [
+ {"type": "thinking_delta", "index": 1, "text": " more thought"}
+ ]
+
+
+def test_parse_cli_event_content_block_start():
+ event = {
+ "type": "content_block_start",
+ "index": 2,
+ "content_block": {
+ "type": "tool_use",
+ "name": "Task",
+ "input": {"description": "deploy"},
+ },
+ }
+ results = parse_cli_event(event)
+ assert results == [
+ {
+ "type": "tool_use_start",
+ "index": 2,
+ "id": "",
+ "name": "Task",
+ "input": {"description": "deploy"},
+ }
+ ]
+
+
+def test_parse_cli_event_error():
+ event = {"type": "error", "error": {"message": "something failed"}}
+ results = parse_cli_event(event)
+ assert results == [{"type": "error", "message": "something failed"}]
+
+
+def test_parse_cli_event_user_tool_result():
+ event = {
+ "type": "user",
+ "message": {
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "tool_1",
+ "content": "ok",
+ "is_error": False,
+ }
+ ]
+ },
+ }
+ results = parse_cli_event(event)
+ assert results == [
+ {
+ "type": "tool_result",
+ "tool_use_id": "tool_1",
+ "content": "ok",
+ "is_error": False,
+ }
+ ]
+
+
+def test_parse_cli_event_exit_success():
+ event = {"type": "exit", "code": 0}
+ results = parse_cli_event(event)
+ assert results == [{"type": "complete", "status": "success"}]
+
+
+def test_parse_cli_event_exit_failure():
+ event = {"type": "exit", "code": 1, "stderr": "fatal error"}
+ results = parse_cli_event(event)
+ assert len(results) == 2
+ assert results[0] == {"type": "error", "message": "fatal error"}
+ assert results[1] == {"type": "complete", "status": "failed"}
+
+
+def test_parse_cli_event_invalid_input():
+ assert parse_cli_event(None) == []
+ assert parse_cli_event("not a dict") == []
+ assert parse_cli_event({"type": "unknown"}) == []
+
+
+def test_parse_cli_event_system_ignored():
+ assert parse_cli_event({"type": "system", "foo": "bar"}) == []
+
+
+def test_parse_cli_event_result_with_content_directly():
+ event = {"type": "result", "content": [{"type": "text", "text": "hi"}]}
+ assert parse_cli_event(event) == [{"type": "text_chunk", "text": "hi"}]
+
+
+def test_parse_cli_event_result_with_result_content_directly():
+ event = {"type": "result", "result": {"content": [{"type": "text", "text": "hi"}]}}
+ assert parse_cli_event(event) == [{"type": "text_chunk", "text": "hi"}]
+
+
+def test_parse_cli_event_content_block_unknown_type_skipped():
+ """Content block with unknown type is skipped; known blocks still parsed."""
+ event = {
+ "type": "assistant",
+ "message": {
+ "content": [
+ {"type": "text", "text": "visible"},
+ {"type": "unknown", "data": "ignored"},
+ {"type": "thinking", "thinking": "thought"},
+ ]
+ },
+ }
+ results = parse_cli_event(event)
+ assert len(results) == 2
+ assert results[0] == {"type": "text_chunk", "text": "visible"}
+ assert results[1] == {"type": "thinking_chunk", "text": "thought"}
+
+
+def test_parse_cli_event_error_non_dict():
+ """Error event with error as string (not dict) is handled."""
+ event = {"type": "error", "error": "plain string error"}
+ results = parse_cli_event(event)
+ assert results == [{"type": "error", "message": "plain string error"}]
+
+
+def test_parse_cli_event_exit_code_none():
+ """Exit event with no code defaults to success."""
+ event = {"type": "exit"}
+ results = parse_cli_event(event)
+ assert results == [{"type": "complete", "status": "success"}]
diff --git a/Claude_Code/tests/messaging/test_extract_text.py b/Claude_Code/tests/messaging/test_extract_text.py
new file mode 100644
index 0000000000000000000000000000000000000000..b22345933269ac0ec0389d21f2823ea38d08bcba
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_extract_text.py
@@ -0,0 +1,109 @@
+"""Tests for extract_text_from_content helper functions."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from providers.common.text import extract_text_from_content
+
+
+class TestExtractTextFromContent:
+ """Tests for providers.common.text.extract_text_from_content."""
+
+ def test_string_content(self):
+ """Return string content as-is."""
+ assert extract_text_from_content("hello world") == "hello world"
+
+ def test_empty_string(self):
+ """Return empty string for empty string input."""
+ assert extract_text_from_content("") == ""
+
+ def test_list_single_block(self):
+ """Extract text from a single content block."""
+ block = MagicMock()
+ block.text = "some text"
+ assert extract_text_from_content([block]) == "some text"
+
+ def test_list_multiple_blocks(self):
+ """Concatenate text from multiple content blocks."""
+ b1 = MagicMock()
+ b1.text = "hello "
+ b2 = MagicMock()
+ b2.text = "world"
+ assert extract_text_from_content([b1, b2]) == "hello world"
+
+ def test_list_with_non_text_block(self):
+ """Skip blocks without text attribute."""
+ b1 = MagicMock()
+ b1.text = "hello"
+ b2 = MagicMock(spec=[]) # No attributes
+ assert extract_text_from_content([b1, b2]) == "hello"
+
+ def test_list_with_empty_text(self):
+ """Skip blocks with empty text."""
+ b1 = MagicMock()
+ b1.text = ""
+ b2 = MagicMock()
+ b2.text = "world"
+ assert extract_text_from_content([b1, b2]) == "world"
+
+ def test_list_with_none_text(self):
+ """Skip blocks with None text."""
+ b1 = MagicMock()
+ b1.text = None
+ b2 = MagicMock()
+ b2.text = "world"
+ assert extract_text_from_content([b1, b2]) == "world"
+
+ def test_empty_list(self):
+ """Return empty string for empty list."""
+ assert extract_text_from_content([]) == ""
+
+ def test_non_string_non_list(self):
+ """Return empty string for unexpected types."""
+ assert extract_text_from_content(None) == ""
+ assert extract_text_from_content(42) == ""
+
+ def test_list_with_non_string_text_attr(self):
+ """Skip blocks where text is not a string."""
+ b1 = MagicMock()
+ b1.text = 123 # Not a string
+ b2 = MagicMock()
+ b2.text = "valid"
+ assert extract_text_from_content([b1, b2]) == "valid"
+
+
+# --- Parametrized Edge Case Tests ---
+
+
+def _make_block(text_val):
+ b = MagicMock()
+ b.text = text_val
+ return b
+
+
+@pytest.mark.parametrize(
+ "content,expected",
+ [
+ ("hello world", "hello world"),
+ ("", ""),
+ (None, ""),
+ (42, ""),
+ ([], ""),
+ (" ", " "),
+ ],
+ ids=["string", "empty_str", "none", "int", "empty_list", "whitespace_only"],
+)
+def test_extract_text_scalar_and_empty_parametrized(content, expected):
+ """Parametrized scalar and empty input handling."""
+ assert extract_text_from_content(content) == expected
+
+
+def test_extract_functions_whitespace_only():
+ """extract_text_from_content handles whitespace-only string."""
+ assert extract_text_from_content(" ") == " "
+
+
+def test_extract_functions_unicode():
+ """extract_text_from_content handles unicode content."""
+ assert extract_text_from_content("日本語テスト") == "日本語テスト"
diff --git a/Claude_Code/tests/messaging/test_handler.py b/Claude_Code/tests/messaging/test_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..7695dd0445efc1ffb4af0ca344e8d7915aa89147
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_handler.py
@@ -0,0 +1,643 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from messaging.handler import ClaudeMessageHandler
+from messaging.models import IncomingMessage
+from messaging.trees.data import MessageNode, MessageTree
+from messaging.trees.queue_manager import MessageState
+
+
+@pytest.fixture
+def handler(mock_platform, mock_cli_manager, mock_session_store):
+ return ClaudeMessageHandler(mock_platform, mock_cli_manager, mock_session_store)
+
+
+def test_get_initial_status_new_conversation(handler):
+ """New conversation always returns launching message."""
+ result = handler._get_initial_status(None, None)
+ assert "Launching" in result
+
+
+def test_get_initial_status_reply_tree_busy_queued(handler):
+ """Reply to tree when busy returns queued message."""
+ mock_queue = MagicMock()
+ mock_queue.is_node_tree_busy.return_value = True
+ mock_queue.get_queue_size.return_value = 2
+ handler.replace_tree_queue(mock_queue)
+ result = handler._get_initial_status(MagicMock(), "parent_1")
+ assert "Queued" in result
+ assert "position 3" in result
+
+
+def test_get_initial_status_reply_tree_not_busy_continuing(handler):
+ """Reply to tree when not busy returns continuing message."""
+ mock_queue = MagicMock()
+ mock_queue.is_node_tree_busy.return_value = False
+ handler.replace_tree_queue(mock_queue)
+ result = handler._get_initial_status(MagicMock(), "parent_1")
+ assert "Continuing" in result
+
+
+@pytest.mark.asyncio
+async def test_handle_message_stop_command(
+ handler, mock_platform, incoming_message_factory
+):
+ incoming = incoming_message_factory(text="/stop")
+
+ # Mock stop_all_tasks
+ handler.stop_all_tasks = AsyncMock(return_value=5)
+
+ await handler.handle_message(incoming)
+
+ handler.stop_all_tasks.assert_called_once()
+ mock_platform.queue_send_message.assert_called_once_with(
+ incoming.chat_id,
+ "⏹ *Stopped\\.* Cancelled 5 pending or active requests\\.",
+ fire_and_forget=False,
+ message_thread_id=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_message_stop_command_reply_stops_only_target_node(
+ handler, mock_platform, mock_cli_manager, incoming_message_factory
+):
+ # Create a tree with a root node and register its status message ID mapping.
+ root_incoming = incoming_message_factory(
+ text="do something", message_id="root_msg", reply_to_message_id=None
+ )
+ tree = await handler.tree_queue.create_tree(
+ node_id="root_msg",
+ incoming=root_incoming,
+ status_message_id="status_root",
+ )
+ handler.tree_queue.register_node("status_root", tree.root_id)
+
+ # Reply "/stop" to the status message; should stop only that node.
+ incoming = incoming_message_factory(
+ text="/stop",
+ message_id="stop_msg",
+ reply_to_message_id="status_root",
+ )
+
+ handler.stop_all_tasks = AsyncMock(return_value=999)
+
+ await handler.handle_message(incoming)
+
+ handler.stop_all_tasks.assert_not_called()
+ mock_cli_manager.stop_all.assert_not_called()
+ assert tree.get_node("root_msg").state == MessageState.ERROR
+ mock_platform.queue_send_message.assert_called_once_with(
+ incoming.chat_id,
+ "⏹ *Stopped\\.* Cancelled 1 request\\.",
+ fire_and_forget=False,
+ message_thread_id=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_message_stop_command_reply_unknown_does_not_stop_all(
+ handler, mock_platform, mock_cli_manager, incoming_message_factory
+):
+ incoming = incoming_message_factory(
+ text="/stop",
+ message_id="stop_msg",
+ reply_to_message_id="unknown_msg",
+ )
+
+ handler.stop_all_tasks = AsyncMock(return_value=5)
+
+ await handler.handle_message(incoming)
+
+ handler.stop_all_tasks.assert_not_called()
+ mock_cli_manager.stop_all.assert_not_called()
+ mock_platform.queue_send_message.assert_called_once_with(
+ incoming.chat_id,
+ "⏹ *Stopped\\.* Nothing to stop for that message\\.",
+ fire_and_forget=False,
+ message_thread_id=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_message_stats_command(
+ handler, mock_platform, mock_cli_manager, incoming_message_factory
+):
+ incoming = incoming_message_factory(text="/stats")
+ mock_cli_manager.get_stats.return_value = {"active_sessions": 2}
+
+ await handler.handle_message(incoming)
+
+ mock_platform.queue_send_message.assert_called_once()
+ args, kwargs = mock_platform.queue_send_message.call_args
+ assert "Active CLI: 2" in args[1]
+ assert kwargs["fire_and_forget"] is False
+ assert kwargs.get("message_thread_id") is None
+
+
+@pytest.mark.asyncio
+async def test_handle_message_filters_status_messages(
+ handler, mock_platform, incoming_message_factory
+):
+ incoming = incoming_message_factory(text="⏳ Thinking...")
+
+ await handler.handle_message(incoming)
+
+ mock_platform.queue_send_message.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_handle_message_new_conversation(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ incoming = incoming_message_factory(text="hello")
+ mock_platform.queue_send_message.return_value = "status_123"
+
+ # We need to mock tree_queue methods
+ with (
+ patch.object(handler.tree_queue, "create_tree", AsyncMock()) as mock_create,
+ patch.object(
+ handler.tree_queue, "enqueue", AsyncMock(return_value=False)
+ ) as mock_enqueue,
+ ):
+ mock_tree = MagicMock()
+ mock_tree.root_id = "root_1"
+ mock_tree.to_dict.return_value = {"data": "tree"}
+ mock_create.return_value = mock_tree
+
+ await handler.handle_message(incoming)
+
+ mock_create.assert_called_once()
+ mock_enqueue.assert_called_once()
+ mock_session_store.save_tree.assert_called_once_with("root_1", {"data": "tree"})
+
+
+@pytest.mark.asyncio
+async def test_handle_message_queued(handler, mock_platform, incoming_message_factory):
+ incoming = incoming_message_factory(text="hello", message_id="msg_1")
+ mock_platform.queue_send_message.return_value = "status_123"
+
+ with (
+ patch.object(handler.tree_queue, "create_tree", AsyncMock()) as mock_create,
+ patch.object(handler.tree_queue, "enqueue", AsyncMock(return_value=True)),
+ patch.object(handler.tree_queue, "get_queue_size", MagicMock(return_value=3)),
+ ):
+ mock_tree = MagicMock()
+ mock_tree.root_id = "root_1"
+ mock_tree.to_dict.return_value = {}
+ mock_create.return_value = mock_tree
+
+ await handler.handle_message(incoming)
+
+ mock_platform.queue_edit_message.assert_called_once_with(
+ incoming.chat_id,
+ "status_123",
+ "📋 *Queued* \\(position 3\\) \\- waiting\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+
+@pytest.mark.asyncio
+async def test_update_queue_positions(handler, mock_platform):
+ root_incoming = IncomingMessage(
+ text="Root",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="root",
+ platform="telegram",
+ )
+ root = MessageNode(
+ node_id="root",
+ incoming=root_incoming,
+ status_message_id="status_root",
+ )
+ tree = MessageTree(root)
+
+ child_incoming_1 = IncomingMessage(
+ text="Child 1",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="child_1",
+ platform="telegram",
+ reply_to_message_id="root",
+ )
+ child_incoming_2 = IncomingMessage(
+ text="Child 2",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="child_2",
+ platform="telegram",
+ reply_to_message_id="root",
+ )
+
+ await tree.add_node(
+ node_id="child_1",
+ incoming=child_incoming_1,
+ status_message_id="status_1",
+ parent_id="root",
+ )
+ await tree.add_node(
+ node_id="child_2",
+ incoming=child_incoming_2,
+ status_message_id="status_2",
+ parent_id="root",
+ )
+
+ await tree.enqueue("child_1")
+ await tree.enqueue("child_2")
+
+ await handler.update_queue_positions(tree)
+
+ calls = mock_platform.queue_edit_message.call_args_list
+ assert len(calls) == 2
+ assert calls[0][0][0] == "chat_1"
+ assert calls[0][0][1] == "status_1"
+ assert "position 1" in calls[0][0][2]
+ assert calls[1][0][0] == "chat_1"
+ assert calls[1][0][1] == "status_2"
+ assert "position 2" in calls[1][0][2]
+
+
+@pytest.mark.asyncio
+async def test_mark_node_processing(handler, mock_platform):
+ root_incoming = IncomingMessage(
+ text="Root",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="root",
+ platform="telegram",
+ )
+ root = MessageNode(
+ node_id="root",
+ incoming=root_incoming,
+ status_message_id="status_root",
+ )
+ tree = MessageTree(root)
+
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="child",
+ platform="telegram",
+ reply_to_message_id="root",
+ )
+
+ await tree.add_node(
+ node_id="child",
+ incoming=child_incoming,
+ status_message_id="status_child",
+ parent_id="root",
+ )
+
+ await handler.mark_node_processing(tree, "child")
+
+ mock_platform.queue_edit_message.assert_called_once()
+ args, kwargs = mock_platform.queue_edit_message.call_args
+ assert args[0] == "chat_1"
+ assert args[1] == "status_child"
+ assert "Processing" in args[2]
+ assert kwargs["parse_mode"] == "MarkdownV2"
+
+
+@pytest.mark.asyncio
+async def test_stop_all_tasks(handler, mock_cli_manager, mock_platform):
+ mock_node = MagicMock()
+ mock_node.incoming.chat_id = "chat_1"
+ mock_node.status_message_id = "status_1"
+
+ with patch.object(
+ handler.tree_queue, "cancel_all", AsyncMock(return_value=[mock_node])
+ ):
+ count = await handler.stop_all_tasks()
+
+ assert count == 1
+ mock_cli_manager.stop_all.assert_called_once()
+ mock_platform.fire_and_forget.assert_called_once()
+
+
+async def mock_async_gen(events):
+ for e in events:
+ yield e
+
+
+@pytest.mark.asyncio
+async def test_process_node_success_flow(handler, mock_cli_manager, mock_platform):
+ # Setup
+ node_id = "node_1"
+ mock_node = MagicMock()
+ mock_node.incoming.chat_id = "chat_1"
+ mock_node.incoming.text = "hello"
+ mock_node.status_message_id = "status_1"
+ mock_node.parent_id = None
+
+ mock_session = MagicMock()
+ # Mock start_task to return our async generator
+ events = [
+ {
+ "type": "assistant",
+ "message": {"content": [{"type": "thinking", "thinking": "Let me think"}]},
+ },
+ {
+ "type": "assistant",
+ "message": {"content": [{"type": "text", "text": "Hello world"}]},
+ },
+ {"type": "exit", "code": 0},
+ ]
+ mock_session.start_task.return_value = mock_async_gen(events)
+
+ mock_cli_manager.get_or_create_session.return_value = (
+ mock_session,
+ "session_1",
+ False,
+ )
+
+ mock_tree = MagicMock()
+ mock_tree.update_state = AsyncMock()
+ mock_tree.root_id = "root_1"
+ mock_tree.to_dict.return_value = {}
+
+ with patch.object(
+ handler.tree_queue, "get_tree_for_node", MagicMock(return_value=mock_tree)
+ ):
+ await handler._process_node(node_id, mock_node)
+
+ # Verify state updates
+ mock_tree.update_state.assert_any_call(node_id, MessageState.IN_PROGRESS)
+ mock_tree.update_state.assert_any_call(
+ node_id, MessageState.COMPLETED, session_id="session_1"
+ )
+
+ # Verify UI updates (at least the final one)
+ # Note: update_ui is debounced, but COMPLETED/ERROR/CANCELLED are forced
+ mock_platform.queue_edit_message.assert_called()
+ last_call = mock_platform.queue_edit_message.call_args_list[-1]
+ assert "✅ *Complete*" in last_call[0][2]
+ assert "Hello world" in last_call[0][2]
+
+
+@pytest.mark.asyncio
+async def test_process_node_error_flow(handler, mock_cli_manager, mock_platform):
+ node_id = "node_1"
+ mock_node = MagicMock()
+ mock_node.incoming.chat_id = "chat_1"
+ mock_node.incoming.text = "hello"
+ mock_node.status_message_id = "status_1"
+
+ mock_session = MagicMock()
+ events = [{"type": "error", "error": {"message": "CLI crashed"}}]
+ mock_session.start_task.return_value = mock_async_gen(events)
+ mock_cli_manager.get_or_create_session.return_value = (
+ mock_session,
+ "session_1",
+ False,
+ )
+
+ mock_tree = MagicMock()
+ mock_tree.update_state = AsyncMock()
+
+ with (
+ patch.object(
+ handler.tree_queue, "get_tree_for_node", MagicMock(return_value=mock_tree)
+ ),
+ patch.object(
+ handler.tree_queue, "mark_node_error", AsyncMock(return_value=[mock_node])
+ ),
+ ):
+ await handler._process_node(node_id, mock_node)
+
+ handler.tree_queue.mark_node_error.assert_called_once_with(
+ node_id, "CLI crashed", propagate_to_children=True
+ )
+
+ last_call = mock_platform.queue_edit_message.call_args_list[-1]
+ assert "❌ *Error*" in last_call[0][2]
+ assert "CLI crashed" in last_call[0][2]
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_stops_deletes_and_wipes_state(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ # Create some tracked messages across two chats. /clear should only delete
+ # messages for the current chat.
+ root_1 = incoming_message_factory(
+ text="do something",
+ chat_id="chat_1",
+ message_id="100",
+ reply_to_message_id=None,
+ )
+ await handler.tree_queue.create_tree(
+ node_id="100",
+ incoming=root_1,
+ status_message_id="101",
+ )
+
+ root_2 = incoming_message_factory(
+ text="other chat",
+ chat_id="chat_2",
+ message_id="200",
+ reply_to_message_id=None,
+ )
+ await handler.tree_queue.create_tree(
+ node_id="200",
+ incoming=root_2,
+ status_message_id="201",
+ )
+
+ events = []
+
+ async def _stop():
+ events.append("stop")
+ return 0
+
+ async def _del(chat_id, message_id, fire_and_forget=True):
+ events.append(f"del:{chat_id}:{message_id}:{fire_and_forget}")
+
+ handler.stop_all_tasks = AsyncMock(side_effect=_stop)
+ mock_platform.queue_delete_message = AsyncMock(side_effect=_del)
+
+ incoming = incoming_message_factory(
+ text="/clear", chat_id="chat_1", message_id="150"
+ )
+ await handler.handle_message(incoming)
+
+ assert events and events[0] == "stop"
+ deleted_ids = {e.split(":")[2] for e in events[1:]}
+ assert deleted_ids == {"100", "101", "150"}
+ assert all(e.endswith(":False") for e in events[1:])
+
+ mock_session_store.clear_all.assert_called_once()
+ assert handler.tree_queue.get_tree_count() == 0
+ mock_platform.queue_send_message.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_with_mention(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ handler.stop_all_tasks = AsyncMock(return_value=0)
+
+ incoming = incoming_message_factory(
+ text="/clear@MyBot", chat_id="chat_1", message_id="10"
+ )
+ await handler.handle_message(incoming)
+
+ handler.stop_all_tasks.assert_called_once()
+ mock_platform.queue_delete_message.assert_called_once_with(
+ "chat_1",
+ "10",
+ fire_and_forget=False,
+ )
+ mock_session_store.clear_all.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_deletes_message_log_ids(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ handler.stop_all_tasks = AsyncMock(return_value=0)
+ mock_session_store.get_message_ids_for_chat.return_value = ["42", "43"]
+
+ incoming = incoming_message_factory(
+ text="/clear", chat_id="chat_1", message_id="150"
+ )
+ await handler.handle_message(incoming)
+
+ deleted = {c.args[1] for c in mock_platform.queue_delete_message.call_args_list}
+ assert deleted == {"42", "43", "150"}
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_reply_clears_branch(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ """Reply /clear to a message clears only that branch."""
+ root_incoming = incoming_message_factory(
+ text="root", chat_id="chat_1", message_id="100", reply_to_message_id=None
+ )
+ tree = await handler.tree_queue.create_tree(
+ node_id="100", incoming=root_incoming, status_message_id="101"
+ )
+ handler.tree_queue.register_node("101", tree.root_id)
+
+ child_incoming = incoming_message_factory(
+ text="child",
+ chat_id="chat_1",
+ message_id="102",
+ reply_to_message_id="100",
+ )
+ await handler.tree_queue.add_to_tree(
+ parent_node_id="100",
+ node_id="102",
+ incoming=child_incoming,
+ status_message_id="103",
+ )
+
+ deleted_ids = []
+
+ async def _capture_delete(chat_id, message_id, fire_and_forget=True):
+ deleted_ids.append(message_id)
+
+ mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
+
+ incoming = incoming_message_factory(
+ text="/clear",
+ chat_id="chat_1",
+ message_id="150",
+ reply_to_message_id="102",
+ )
+ await handler.handle_message(incoming)
+
+ assert set(deleted_ids) == {"102", "103", "150"}
+ assert "100" not in deleted_ids
+ assert "101" not in deleted_ids
+ mock_session_store.remove_node_mappings.assert_called()
+ assert handler.tree_queue.get_tree_for_node("102") is None
+ assert handler.tree_queue.get_tree_for_node("100") is not None
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_reply_unknown_sends_nothing(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ """Reply /clear to unknown message sends 'Nothing to clear'."""
+ incoming = incoming_message_factory(
+ text="/clear",
+ chat_id="chat_1",
+ message_id="150",
+ reply_to_message_id="999",
+ )
+ await handler.handle_message(incoming)
+
+ mock_platform.queue_send_message.assert_called_once()
+ call_args = mock_platform.queue_send_message.call_args[0]
+ assert "Nothing to clear" in call_args[1]
+ mock_session_store.clear_all.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_reply_to_root_clears_tree(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ """Reply /clear to root message clears entire tree."""
+ root_incoming = incoming_message_factory(
+ text="root", chat_id="chat_1", message_id="100", reply_to_message_id=None
+ )
+ await handler.tree_queue.create_tree(
+ node_id="100", incoming=root_incoming, status_message_id="101"
+ )
+
+ deleted_ids = []
+
+ async def _capture_delete(chat_id, message_id, fire_and_forget=True):
+ deleted_ids.append(message_id)
+
+ mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
+
+ incoming = incoming_message_factory(
+ text="/clear",
+ chat_id="chat_1",
+ message_id="150",
+ reply_to_message_id="100",
+ )
+ await handler.handle_message(incoming)
+
+ assert set(deleted_ids) == {"100", "101", "150"}
+ mock_session_store.remove_tree.assert_called_once_with("100")
+ assert handler.tree_queue.get_tree_count() == 0
+
+
+@pytest.mark.asyncio
+async def test_handle_message_clear_command_reply_pending_voice_cancels(
+ handler, mock_platform, mock_session_store, incoming_message_factory
+):
+ """Reply /clear to a voice note during transcription cancels it."""
+
+ async def cancel_pending(chat_id, reply_id):
+ if reply_id == "100":
+ return ("100", "101")
+ return None
+
+ mock_platform.cancel_pending_voice = AsyncMock(side_effect=cancel_pending)
+ mock_platform.queue_delete_message = AsyncMock()
+ deleted_ids = []
+
+ async def _capture_delete(chat_id, message_id, fire_and_forget=True):
+ deleted_ids.append(message_id)
+
+ mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
+
+ incoming = incoming_message_factory(
+ text="/clear",
+ chat_id="chat_1",
+ message_id="150",
+ reply_to_message_id="100",
+ )
+ await handler.handle_message(incoming)
+
+ mock_platform.cancel_pending_voice.assert_called_once_with("chat_1", "100")
+ assert set(deleted_ids) == {"100", "101", "150"}
+ call_args = mock_platform.queue_send_message.call_args[0]
+ assert "Voice note cancelled" in call_args[1]
diff --git a/Claude_Code/tests/messaging/test_handler_context_isolation.py b/Claude_Code/tests/messaging/test_handler_context_isolation.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc180906e6217179f481b822d773844dd171366c
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_handler_context_isolation.py
@@ -0,0 +1,158 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from messaging.handler import ClaudeMessageHandler
+from messaging.trees.data import MessageState
+
+
+async def _gen_session(events):
+ for e in events:
+ yield e
+
+
+@pytest.fixture
+def handler(mock_platform, mock_cli_manager, mock_session_store):
+ return ClaudeMessageHandler(mock_platform, mock_cli_manager, mock_session_store)
+
+
+@pytest.mark.asyncio
+async def test_sibling_replies_fork_from_parent_session_id(
+ handler, mock_cli_manager, incoming_message_factory
+):
+ # Root node A with a known session_id.
+ root_incoming = incoming_message_factory(text="A", message_id="A")
+ tree = await handler.tree_queue.create_tree(
+ node_id="A", incoming=root_incoming, status_message_id="status_A"
+ )
+ await tree.update_state("A", MessageState.COMPLETED, session_id="sess_A")
+
+ # Add two sibling replies R1 and R2 under A.
+ r1_incoming = incoming_message_factory(
+ text="R1", message_id="R1", reply_to_message_id="A"
+ )
+ r2_incoming = incoming_message_factory(
+ text="R2", message_id="R2", reply_to_message_id="A"
+ )
+ _, r1_node = await handler.tree_queue.add_to_tree(
+ "A", "R1", r1_incoming, "status_R1"
+ )
+ _, r2_node = await handler.tree_queue.add_to_tree(
+ "A", "R2", r2_incoming, "status_R2"
+ )
+
+ # Mock a fresh cli_session per node.
+ calls = []
+
+ async def _get_or_create_session(session_id=None):
+ cli_session = MagicMock()
+
+ async def _start_task(prompt, session_id=None, fork_session=False):
+ calls.append((prompt, session_id, fork_session))
+ child_sid = f"sess_{prompt}"
+ async for ev in _gen_session(
+ [
+ {"type": "session_info", "session_id": child_sid},
+ {"type": "exit", "code": 0, "stderr": None},
+ ]
+ ):
+ yield ev
+
+ cli_session.start_task = _start_task
+ return cli_session, f"pending_{len(calls) + 1}", True
+
+ mock_cli_manager.get_or_create_session = AsyncMock(
+ side_effect=_get_or_create_session
+ )
+
+ await handler._process_node("R1", r1_node)
+ await handler._process_node("R2", r2_node)
+
+ # Both siblings must resume from the same parent session and fork.
+ assert calls[0][0] == "R1"
+ assert calls[0][1] == "sess_A"
+ assert calls[0][2] is True
+
+ assert calls[1][0] == "R2"
+ assert calls[1][1] == "sess_A"
+ assert calls[1][2] is True
+
+
+@pytest.mark.asyncio
+async def test_grandchild_reply_forks_from_branch_session(
+ handler, mock_cli_manager, incoming_message_factory
+):
+ root_incoming = incoming_message_factory(text="A", message_id="A")
+ tree = await handler.tree_queue.create_tree(
+ node_id="A", incoming=root_incoming, status_message_id="status_A"
+ )
+ await tree.update_state("A", MessageState.COMPLETED, session_id="sess_A")
+
+ r1_incoming = incoming_message_factory(
+ text="R1", message_id="R1", reply_to_message_id="A"
+ )
+ _, r1_node = await handler.tree_queue.add_to_tree(
+ "A", "R1", r1_incoming, "status_R1"
+ )
+
+ calls = []
+
+ async def _get_or_create_session(session_id=None):
+ cli_session = MagicMock()
+
+ async def _start_task(prompt, session_id=None, fork_session=False):
+ calls.append((prompt, session_id, fork_session))
+ # R1 gets its own forked session id.
+ child_sid = "sess_R1"
+ async for ev in _gen_session(
+ [
+ {"type": "session_info", "session_id": child_sid},
+ {"type": "exit", "code": 0, "stderr": None},
+ ]
+ ):
+ yield ev
+
+ cli_session.start_task = _start_task
+ return cli_session, "pending_R1", True
+
+ mock_cli_manager.get_or_create_session = AsyncMock(
+ side_effect=_get_or_create_session
+ )
+
+ await handler._process_node("R1", r1_node)
+ assert r1_node.session_id == "sess_R1"
+
+ # Grandchild C1 replies to R1 and must fork from sess_R1, not sess_A.
+ c1_incoming = incoming_message_factory(
+ text="C1", message_id="C1", reply_to_message_id="R1"
+ )
+ _, c1_node = await handler.tree_queue.add_to_tree(
+ "R1", "C1", c1_incoming, "status_C1"
+ )
+
+ async def _get_or_create_session_c1(session_id=None):
+ cli_session = MagicMock()
+
+ async def _start_task(prompt, session_id=None, fork_session=False):
+ calls.append((prompt, session_id, fork_session))
+ async for ev in _gen_session(
+ [
+ {"type": "session_info", "session_id": "sess_C1"},
+ {"type": "exit", "code": 0, "stderr": None},
+ ]
+ ):
+ yield ev
+
+ cli_session.start_task = _start_task
+ return cli_session, "pending_C1", True
+
+ mock_cli_manager.get_or_create_session = AsyncMock(
+ side_effect=_get_or_create_session_c1
+ )
+
+ await handler._process_node("C1", c1_node)
+
+ # The last call should be for C1 and must resume from sess_R1.
+ assert calls[-1][0] == "C1"
+ assert calls[-1][1] == "sess_R1"
+ assert calls[-1][2] is True
diff --git a/Claude_Code/tests/messaging/test_handler_format.py b/Claude_Code/tests/messaging/test_handler_format.py
new file mode 100644
index 0000000000000000000000000000000000000000..2cf60ee3bcbb211bb63887341748f2d5c88bbe72
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_handler_format.py
@@ -0,0 +1,132 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from messaging.rendering.telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+)
+from messaging.transcript import RenderCtx, TranscriptBuffer
+
+
+@pytest.fixture
+def handler():
+ platform = MagicMock()
+ cli = MagicMock()
+ store = MagicMock()
+ # Kept for backwards test structure; transcript rendering is now separate.
+ return (platform, cli, store)
+
+
+def _ctx() -> RenderCtx:
+ return RenderCtx(
+ bold=mdv2_bold,
+ code_inline=mdv2_code_inline,
+ escape_code=escape_md_v2_code,
+ escape_text=escape_md_v2,
+ render_markdown=render_markdown_to_mdv2,
+ )
+
+
+def test_transcript_structure_and_order(handler):
+ """Verify ordered transcript rendering (thinking/tool/subagent/text/error/status)."""
+ status = "✅ *Complete*"
+ t = TranscriptBuffer()
+
+ # Apply in a deliberate sequence.
+ t.apply({"type": "thinking_chunk", "text": "Thinking process..."})
+ t.apply(
+ {"type": "tool_use", "id": "t1", "name": "list_files", "input": {"path": "."}}
+ )
+
+ # Subagent marker (Task tool).
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task1",
+ "name": "Task",
+ "input": {"description": "Searching codebase..."},
+ }
+ )
+ t.apply(
+ {"type": "tool_use", "id": "t2", "name": "read_file", "input": {"path": "x.py"}}
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "task1", "content": "done"})
+
+ t.apply({"type": "text_chunk", "text": "Here is the file content."})
+ t.apply({"type": "error", "message": "Some error happened"})
+
+ msg = t.render(_ctx(), limit_chars=3900, status=status)
+
+ print(f"Generated Message:\n{msg}")
+
+ # Check existence
+ assert "Thinking process..." in msg
+ assert "list_files" in msg
+ assert "read_file" in msg
+ assert "Searching codebase..." in msg
+ assert escape_md_v2("Here is the file content.") in msg
+ assert "Some error happened" in msg
+ assert "✅ *Complete*" in msg
+
+ # Check headers/markers used in the transcript.
+ assert "💭 *Thinking*" in msg
+ assert "🛠 *Tool call:*" in msg
+ assert "🤖 *Subagent:*" in msg
+ assert "⚠️ *Error:*" in msg
+
+ # Check Order: Thinking -> Tool call -> Subagent -> Content -> Errors -> Status
+ p_thinking = msg.find("Thinking process...")
+ p_tool_call = msg.find("🛠 *Tool call:*")
+ p_subagent = msg.find("🤖 *Subagent:*")
+ p_content = msg.find(escape_md_v2("Here is the file content."))
+ p_errors = msg.find("⚠️ *Error:*")
+ p_status = msg.find("✅ *Complete*")
+
+ assert p_thinking < p_tool_call, "Thinking should come before tool calls"
+ assert p_tool_call < p_subagent, "Tool calls should come before subagent marker"
+ assert p_subagent < p_content, "Subagent should come before Content"
+ assert p_content < p_errors, "Content should come before Errors"
+ assert p_errors < p_status, "Errors should come before Status"
+
+
+def test_transcript_simple(handler):
+ """Verify simple transcript with just text + status."""
+ t = TranscriptBuffer()
+ t.apply({"type": "text_chunk", "text": "Simple message."})
+ msg = t.render(_ctx(), limit_chars=3900, status="Ready")
+
+ assert escape_md_v2("Simple message.") in msg
+ assert "Ready" in msg
+ assert "💭" not in msg
+ assert "🛠" not in msg
+
+
+def test_subagents_formatting(handler):
+ """Verify subagent formatting (Task tool)."""
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_1",
+ "name": "Task",
+ "input": {"description": "Task 1"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "task_1", "content": "done"})
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_2",
+ "name": "Task",
+ "input": {"description": "Task 2"},
+ }
+ )
+
+ msg = t.render(_ctx(), limit_chars=3900, status=None)
+
+ assert "🤖 *Subagent:* `Task 1`" in msg
+ assert "🤖 *Subagent:* `Task 2`" in msg
diff --git a/Claude_Code/tests/messaging/test_handler_integration.py b/Claude_Code/tests/messaging/test_handler_integration.py
new file mode 100644
index 0000000000000000000000000000000000000000..b98375d70a0980766b7cc86983bb5f552d4f450c
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_handler_integration.py
@@ -0,0 +1,187 @@
+import asyncio
+from unittest.mock import MagicMock
+
+import pytest
+
+from messaging.handler import ClaudeMessageHandler
+from messaging.trees.data import MessageState
+
+
+@pytest.fixture
+def handler_integration(mock_platform, mock_cli_manager, mock_session_store):
+ # Use real TreeQueueManager
+ handler = ClaudeMessageHandler(mock_platform, mock_cli_manager, mock_session_store)
+ return handler
+
+
+async def mock_async_gen(events):
+ for e in events:
+ yield e
+
+
+@pytest.mark.asyncio
+async def test_full_conversation_flow_single_user(
+ handler_integration, mock_platform, mock_cli_manager, incoming_message_factory
+):
+ # 1. First message
+ msg1 = incoming_message_factory(text="message 1", message_id="m1")
+ mock_platform.queue_send_message.return_value = "s1"
+
+ # Mock CLI session for m1
+ mock_session1 = MagicMock()
+ mock_session1.start_task.return_value = mock_async_gen(
+ [
+ {"type": "session_info", "session_id": "sess1"},
+ {
+ "type": "assistant",
+ "message": {"content": [{"type": "text", "text": "Reply 1"}]},
+ },
+ {"type": "exit", "code": 0, "stderr": None},
+ ]
+ )
+ mock_cli_manager.get_or_create_session.return_value = (
+ mock_session1,
+ "pending_1",
+ True,
+ )
+
+ await handler_integration.handle_message(msg1)
+
+ # Wait for processing
+ tree = handler_integration.tree_queue.get_tree_for_node("m1")
+ for _ in range(10):
+ if tree.get_node("m1").state.value == MessageState.COMPLETED.value:
+ break
+ await asyncio.sleep(0.01)
+
+ assert tree.get_node("m1").state.value == MessageState.COMPLETED.value
+ assert tree.get_node("m1").session_id == "sess1"
+ mock_session1.start_task.assert_called_with(
+ "message 1", session_id=None, fork_session=False
+ )
+
+ # 2. Reply to m1
+ msg2 = incoming_message_factory(
+ text="message 2", message_id="m2", reply_to_message_id="m1"
+ )
+ mock_platform.queue_send_message.return_value = "s2"
+
+ # Mock CLI session for m2
+ mock_session2 = MagicMock()
+ mock_session2.start_task.return_value = mock_async_gen(
+ [
+ {"type": "session_info", "session_id": "sess2"},
+ {
+ "type": "assistant",
+ "message": {"content": [{"type": "text", "text": "Reply 2"}]},
+ },
+ {"type": "exit", "code": 0, "stderr": None},
+ ]
+ )
+ mock_cli_manager.get_or_create_session.reset_mock()
+ mock_cli_manager.get_or_create_session.return_value = (
+ mock_session2,
+ "pending_2",
+ True,
+ )
+
+ await handler_integration.handle_message(msg2)
+
+ # Wait for processing
+ for _ in range(10):
+ if tree.get_node("m2").state.value == MessageState.COMPLETED.value:
+ break
+ await asyncio.sleep(0.01)
+
+ assert tree.get_node("m2").state.value == MessageState.COMPLETED.value
+ assert tree.get_node("m2").parent_id == "m1"
+ mock_cli_manager.get_or_create_session.assert_called_with(session_id=None)
+ mock_session2.start_task.assert_called_with(
+ "message 2", session_id="sess1", fork_session=True
+ )
+
+
+@pytest.mark.asyncio
+async def test_error_propagation_chain(
+ handler_integration, mock_platform, mock_cli_manager, incoming_message_factory
+):
+ msg1 = incoming_message_factory(text="m1", message_id="m1")
+ mock_platform.queue_send_message.return_value = "s1"
+
+ mock_session1 = MagicMock()
+ mock_session1.start_task.return_value = mock_async_gen(
+ [{"type": "error", "error": {"message": "failed"}}]
+ )
+ mock_cli_manager.get_or_create_session.return_value = (
+ mock_session1,
+ "sess1",
+ False,
+ )
+
+ await handler_integration.handle_message(msg1)
+ tree = handler_integration.tree_queue.get_tree_for_node("m1")
+
+ msg2 = incoming_message_factory(
+ text="m2", message_id="m2", reply_to_message_id="m1"
+ )
+ await handler_integration.handle_message(msg2)
+
+ # Wait for m1 to fail
+ for _ in range(20):
+ if tree.get_node("m1").state.value == MessageState.ERROR.value:
+ break
+ await asyncio.sleep(0.01)
+
+ # Give a tiny bit of time for propagation and skipping in processor
+ await asyncio.sleep(0.05)
+
+ assert tree.get_node("m1").state.value == MessageState.ERROR.value
+ assert tree.get_node("m2").state.value == MessageState.ERROR.value
+ assert "Parent failed" in tree.get_node("m2").error_message
+
+
+@pytest.mark.asyncio
+async def test_concurrent_replies_to_different_trees(
+ handler_integration, mock_platform, mock_cli_manager, incoming_message_factory
+):
+ msg1 = incoming_message_factory(text="t1", message_id="t1")
+ msg2 = incoming_message_factory(text="t2", message_id="t2")
+
+ mock_session1 = MagicMock()
+ mock_session1.start_task.return_value = mock_async_gen(
+ [{"type": "exit", "code": 0}]
+ )
+ mock_session2 = MagicMock()
+ mock_session2.start_task.return_value = mock_async_gen(
+ [{"type": "exit", "code": 0}]
+ )
+
+ mock_cli_manager.get_or_create_session.side_effect = [
+ (mock_session1, "s1", False),
+ (mock_session2, "s2", False),
+ ]
+
+ await handler_integration.handle_message(msg1)
+ await handler_integration.handle_message(msg2)
+
+ # Wait for both
+ for _ in range(20):
+ node1 = handler_integration.tree_queue.get_node("t1")
+ node2 = handler_integration.tree_queue.get_node("t2")
+ if (
+ node1
+ and node2
+ and node1.state.value == MessageState.COMPLETED.value
+ and node2.state.value == MessageState.COMPLETED.value
+ ):
+ break
+ await asyncio.sleep(0.01)
+
+ assert (
+ handler_integration.tree_queue.get_node("t1").state.value
+ == MessageState.COMPLETED.value
+ )
+ assert (
+ handler_integration.tree_queue.get_node("t2").state.value
+ == MessageState.COMPLETED.value
+ )
diff --git a/Claude_Code/tests/messaging/test_handler_markdown_and_status_edges.py b/Claude_Code/tests/messaging/test_handler_markdown_and_status_edges.py
new file mode 100644
index 0000000000000000000000000000000000000000..185e8b1d381bd411de3e4b27a387fa3e95d0f47e
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_handler_markdown_and_status_edges.py
@@ -0,0 +1,411 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from messaging.handler import ClaudeMessageHandler
+from messaging.models import IncomingMessage
+from messaging.rendering.telegram_markdown import render_markdown_to_mdv2
+from messaging.trees.data import MessageNode, MessageState
+
+
+def test_render_markdown_to_mdv2_empty_returns_empty():
+ assert render_markdown_to_mdv2("") == ""
+
+
+def test_render_markdown_to_mdv2_covers_common_structures():
+ md = (
+ "# Heading\n\n"
+ "Text with *em* and **strong** and ~~strike~~ and `code`.\n\n"
+ "- item1\n"
+ "- item2\n\n"
+ "3. third\n\n"
+ "> quote\n\n"
+ "[link](http://example.com/a\\)b)\n\n"
+ "\n\n"
+ "```python\nprint('x')\n```\n"
+ )
+ out = render_markdown_to_mdv2(md)
+ assert "*Heading*" in out
+ assert "_em_" in out
+ assert "*strong*" in out
+ assert "~strike~" in out
+ assert "`code`" in out
+ assert "\\- item1" in out
+ assert "3\\." in out
+ assert "> quote" in out
+ assert "[link]" in out
+ assert "alt (http://example.com/img.png)" in out
+ assert "```" in out
+
+
+def test_render_markdown_to_mdv2_renders_table_as_code_block():
+ md = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter.\n"
+ out = render_markdown_to_mdv2(md)
+ assert "```" in out
+ assert "| a" in out
+ assert "| b" in out
+ assert "| ---" in out
+ assert "After" in out
+
+
+def test_render_markdown_to_mdv2_table_without_blank_line_still_renders():
+ md = "Here's a table:\n| a | b |\n|---|---|\n| 1 | 2 |\n"
+ out = render_markdown_to_mdv2(md)
+ assert "Here's a table" in out
+ assert "```" in out
+ assert "| a" in out
+ assert "| ---" in out
+
+
+def test_render_markdown_to_mdv2_table_escapes_backticks_and_backslashes_in_cells():
+ md = "| a | b |\n|---|---|\n| \\\\ | `` ` `` |\n"
+ out = render_markdown_to_mdv2(md)
+ assert "```" in out
+ # In Telegram code blocks we escape backslashes and backticks.
+ assert "\\\\" in out # rendered cell backslash becomes double-backslash
+ assert "\\`" in out # rendered cell backtick is escaped
+
+
+def test_render_markdown_to_mdv2_table_inside_list_keeps_bullet_prefix():
+ md = "-\n | a | b |\n |---|---|\n | 1 | 2 |\n"
+ out = render_markdown_to_mdv2(md)
+ assert "```" in out
+ assert out.lstrip().startswith("\\-")
+ assert out.find("\\-") < out.find("```")
+
+
+def test_get_initial_status_branches():
+ platform = MagicMock()
+ cli_manager = MagicMock()
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+
+ with (
+ patch.object(
+ handler.tree_queue, "is_node_tree_busy", MagicMock(return_value=True)
+ ),
+ patch.object(handler.tree_queue, "get_queue_size", MagicMock(return_value=2)),
+ ):
+ s1 = handler._get_initial_status(tree=object(), parent_node_id="p")
+ assert "Queued" in s1
+ assert "position 3" in s1 or "position 3" in s1.replace("\\", "")
+
+ with patch.object(
+ handler.tree_queue, "is_node_tree_busy", MagicMock(return_value=False)
+ ):
+ s2 = handler._get_initial_status(tree=object(), parent_node_id="p")
+ assert "Continuing" in s2
+
+ s3 = handler._get_initial_status(tree=None, parent_node_id=None)
+ assert "Launching" in s3
+
+
+@pytest.mark.asyncio
+async def test_update_queue_positions_handles_snapshot_error_and_skips_non_pending():
+ platform = MagicMock()
+ platform.queue_edit_message = AsyncMock()
+ platform.fire_and_forget = MagicMock(
+ side_effect=lambda c: getattr(c, "close", lambda: None)()
+ )
+
+ cli_manager = MagicMock()
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+
+ # Snapshot error is swallowed.
+ tree = MagicMock()
+ tree.get_queue_snapshot = AsyncMock(side_effect=RuntimeError("boom"))
+ await handler.update_queue_positions(tree)
+ platform.fire_and_forget.assert_not_called()
+
+ # Normal path: only PENDING nodes get an update.
+ node_pending = MagicMock()
+ node_pending.state = MessageState.PENDING
+ node_pending.incoming.chat_id = "c"
+ node_pending.status_message_id = "s"
+
+ node_done = MagicMock()
+ node_done.state = MessageState.COMPLETED
+
+ tree.get_queue_snapshot = AsyncMock(return_value=["n1", "n2"])
+ tree.get_node = MagicMock(side_effect=[node_pending, node_done])
+
+ await handler.update_queue_positions(tree)
+ assert platform.fire_and_forget.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_process_node_session_limit_marks_error_and_updates_ui():
+ platform = MagicMock()
+ platform.queue_edit_message = AsyncMock()
+ platform.fire_and_forget = MagicMock(
+ side_effect=lambda c: getattr(c, "close", lambda: None)()
+ )
+
+ cli_manager = MagicMock()
+ cli_manager.get_or_create_session = AsyncMock(side_effect=RuntimeError("limit"))
+ cli_manager.get_stats.return_value = {"active_sessions": 0}
+
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+
+ fake_tree = MagicMock()
+ fake_tree.update_state = AsyncMock()
+ with patch.object(
+ handler.tree_queue, "get_tree_for_node", MagicMock(return_value=fake_tree)
+ ):
+ incoming = IncomingMessage(
+ text="hi",
+ chat_id="c",
+ user_id="u",
+ message_id="n1",
+ platform="telegram",
+ )
+ node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1")
+
+ await handler._process_node("n1", node)
+ assert platform.queue_edit_message.await_count >= 1
+ fake_tree.update_state.assert_awaited()
+
+
+@pytest.mark.asyncio
+async def test_stop_all_tasks_saves_tree_for_cancelled_nodes():
+ platform = MagicMock()
+ platform.queue_edit_message = AsyncMock()
+ platform.fire_and_forget = MagicMock(
+ side_effect=lambda c: getattr(c, "close", lambda: None)()
+ )
+
+ cli_manager = MagicMock()
+ cli_manager.stop_all = AsyncMock()
+ cli_manager.get_stats.return_value = {"active_sessions": 0}
+
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+
+ incoming = IncomingMessage(
+ text="hi",
+ chat_id="c",
+ user_id="u",
+ message_id="n1",
+ platform="telegram",
+ )
+ node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1")
+
+ tree = MagicMock()
+ tree.root_id = "root"
+ tree.to_dict = MagicMock(return_value={"root": "ok"})
+ with (
+ patch.object(handler.tree_queue, "cancel_all", AsyncMock(return_value=[node])),
+ patch.object(
+ handler.tree_queue, "get_tree_for_node", MagicMock(return_value=tree)
+ ),
+ ):
+ count = await handler.stop_all_tasks()
+ assert count == 1
+ cli_manager.stop_all.assert_awaited_once()
+ session_store.save_tree.assert_called_once_with("root", {"root": "ok"})
+
+
+@pytest.mark.asyncio
+async def test_handle_message_reply_with_tree_but_no_parent_treated_as_new():
+ platform = MagicMock()
+ platform.queue_send_message = AsyncMock(return_value="status_1")
+ platform.queue_edit_message = AsyncMock()
+
+ cli_manager = MagicMock()
+ cli_manager.get_stats.return_value = {"active_sessions": 0}
+
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+
+ # Force "tree exists but parent can't be resolved" branch.
+ mock_queue = MagicMock()
+ mock_queue.get_tree_for_node.return_value = object()
+ mock_queue.resolve_parent_node_id.return_value = None
+ mock_queue.create_tree = AsyncMock(
+ return_value=MagicMock(root_id="root", to_dict=MagicMock(return_value={"t": 1}))
+ )
+ mock_queue.register_node = MagicMock()
+ mock_queue.enqueue = AsyncMock(return_value=False)
+ handler.replace_tree_queue(mock_queue)
+
+ incoming = IncomingMessage(
+ text="reply",
+ chat_id="c",
+ user_id="u",
+ message_id="m1",
+ platform="telegram",
+ reply_to_message_id="some_reply",
+ )
+
+ await handler.handle_message(incoming)
+ mock_queue.create_tree.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_update_ui_handles_transcript_render_exception():
+ """When transcript.render raises, update_ui catches and does not crash."""
+ platform = MagicMock()
+ platform.queue_edit_message = AsyncMock()
+ platform.fire_and_forget = MagicMock(
+ side_effect=lambda c: getattr(c, "close", lambda: None)()
+ )
+
+ cli_manager = MagicMock()
+ session_store = MagicMock()
+
+ async def _mock_start_task(*args, **kwargs):
+ yield {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": "hi"},
+ }
+ yield {"type": "complete", "status": "success"}
+
+ mock_session = MagicMock()
+ mock_session.start_task = _mock_start_task
+ cli_manager.get_or_create_session = AsyncMock(
+ return_value=(mock_session, "s1", False)
+ )
+ cli_manager.remove_session = AsyncMock()
+ cli_manager.get_stats.return_value = {"active_sessions": 0}
+
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+ mock_queue = MagicMock()
+ mock_queue.get_tree_for_node.return_value = None
+ handler.replace_tree_queue(mock_queue)
+
+ incoming = IncomingMessage(
+ text="hi",
+ chat_id="c",
+ user_id="u",
+ message_id="n1",
+ platform="telegram",
+ )
+ node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1")
+
+ with patch.object(handler, "_create_transcript_and_render_ctx") as mock_create:
+ transcript = MagicMock()
+ transcript.render = MagicMock(side_effect=ValueError("render failed"))
+ render_ctx = MagicMock()
+ mock_create.return_value = (transcript, render_ctx)
+
+ await handler._process_node("n1", node)
+
+ assert transcript.render.call_count >= 1
+
+
+@pytest.mark.asyncio
+async def test_handle_message_incoming_text_none_safe():
+ """handle_message does not crash when incoming.text is None (e.g. malformed adapter)."""
+ platform = MagicMock()
+ platform.queue_send_message = AsyncMock(return_value="status_1")
+ platform.queue_edit_message = AsyncMock()
+
+ cli_manager = MagicMock()
+ cli_manager.get_stats.return_value = {"active_sessions": 0}
+
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+ mock_queue = MagicMock()
+ mock_queue.get_tree_for_node.return_value = None
+ mock_queue.resolve_parent_node_id.return_value = None
+ mock_queue.create_tree = AsyncMock(
+ return_value=MagicMock(root_id="root", to_dict=MagicMock(return_value={"t": 1}))
+ )
+ mock_queue.register_node = MagicMock()
+ mock_queue.enqueue = AsyncMock(return_value=True)
+ handler.replace_tree_queue(mock_queue)
+
+ incoming = MagicMock()
+ incoming.text = None
+ incoming.chat_id = "c"
+ incoming.user_id = "u"
+ incoming.message_id = "m1"
+ incoming.platform = "telegram"
+ incoming.reply_to_message_id = None
+ incoming.is_reply = MagicMock(return_value=False)
+
+ await handler.handle_message(incoming)
+ mock_queue.create_tree.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_process_parsed_event_malformed_content_continues():
+ """Malformed/unknown parsed event does not crash _process_parsed_event."""
+ platform = MagicMock()
+ platform.queue_edit_message = AsyncMock()
+
+ cli_manager = MagicMock()
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+
+ transcript = MagicMock()
+ update_ui = AsyncMock()
+
+ last_status, had = await handler._process_parsed_event(
+ parsed={"type": "unknown_type"},
+ transcript=transcript,
+ update_ui=update_ui,
+ last_status=None,
+ had_transcript_events=False,
+ tree=None,
+ node_id="n1",
+ captured_session_id=None,
+ )
+ assert last_status is None
+ assert had is False
+
+
+@pytest.mark.asyncio
+async def test_handler_update_ui_edit_failure_does_not_crash():
+ """When queue_edit_message raises during streaming, _process_node continues and completes."""
+ platform = MagicMock()
+ platform.queue_edit_message = AsyncMock(
+ side_effect=RuntimeError("Telegram API error")
+ )
+ platform.fire_and_forget = MagicMock(
+ side_effect=lambda c: getattr(c, "close", lambda: None)()
+ )
+
+ async def _mock_start_task(*args, **kwargs):
+ yield {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": "Hello"},
+ }
+ yield {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": " world"},
+ }
+ yield {"type": "complete", "status": "success"}
+
+ mock_session = MagicMock()
+ mock_session.start_task = _mock_start_task
+ cli_manager = MagicMock()
+ cli_manager.get_or_create_session = AsyncMock(
+ return_value=(mock_session, "s1", False)
+ )
+ cli_manager.remove_session = AsyncMock()
+ cli_manager.get_stats.return_value = {"active_sessions": 0}
+
+ session_store = MagicMock()
+ handler = ClaudeMessageHandler(platform, cli_manager, session_store)
+ mock_queue = MagicMock()
+ mock_queue.get_tree_for_node.return_value = None
+ handler.replace_tree_queue(mock_queue)
+
+ incoming = IncomingMessage(
+ text="hi",
+ chat_id="c",
+ user_id="u",
+ message_id="n1",
+ platform="telegram",
+ )
+ node = MessageNode(node_id="n1", incoming=incoming, status_message_id="s1")
+
+ await handler._process_node("n1", node)
+
+ cli_manager.remove_session.assert_awaited_once()
diff --git a/Claude_Code/tests/messaging/test_limiter.py b/Claude_Code/tests/messaging/test_limiter.py
new file mode 100644
index 0000000000000000000000000000000000000000..565e80f628483beb5a6dfb83d73b2a4e13ca8a49
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_limiter.py
@@ -0,0 +1,267 @@
+import asyncio
+import os
+import time
+
+import pytest
+import pytest_asyncio
+
+# Set environment variables relative to test execution
+os.environ["MESSAGING_RATE_LIMIT"] = "1"
+os.environ["MESSAGING_RATE_WINDOW"] = "0.5"
+
+import contextlib
+
+from messaging.limiter import MessagingRateLimiter
+
+
+class TestMessagingRateLimiter:
+ """Tests for MessagingRateLimiter."""
+
+ @pytest_asyncio.fixture(autouse=True)
+ async def reset_limiter(self):
+ """Reset singleton and environment before each test."""
+ # Ensure the singleton worker is stopped between tests to avoid dangling tasks.
+ await MessagingRateLimiter.shutdown_instance(timeout=0.1)
+ os.environ["MESSAGING_RATE_LIMIT"] = "1"
+ os.environ["MESSAGING_RATE_WINDOW"] = "0.5"
+
+ yield
+
+ await MessagingRateLimiter.shutdown_instance(timeout=0.1)
+
+ @pytest.mark.asyncio
+ async def test_singleton_pattern(self):
+ """Test that get_instance returns the same object."""
+ limiter1 = await MessagingRateLimiter.get_instance()
+ limiter2 = await MessagingRateLimiter.get_instance()
+ assert limiter1 is limiter2
+
+ @pytest.mark.asyncio
+ async def test_compaction(self):
+ """
+ Verify multiple rapid requests with same dedup_key are compacted.
+ Logic ported from verify_limiter.py
+ """
+ # Set slow rate for testing compaction
+ os.environ["MESSAGING_RATE_LIMIT"] = "1"
+ os.environ["MESSAGING_RATE_WINDOW"] = "1.0"
+
+ # Must reset instance to pick up new env vars
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ call_counts = {}
+
+ async def mock_edit(msg_id, content):
+ call_counts[msg_id] = call_counts.get(msg_id, 0) + 1
+ return f"done_{content}"
+
+ # Spam 5 edits
+ for i in range(5):
+ limiter.fire_and_forget(
+ lambda i=i: mock_edit("msg1", f"update_{i}"), dedup_key="edit:msg1"
+ )
+
+ # Wait for processing
+ # 1st might go through immediately, subsequent ones queue and compact
+ await asyncio.sleep(2.5)
+
+ # Expected: ~2 calls (first and last)
+ assert call_counts["msg1"] <= 2, (
+ f"Expected compaction to reduce calls, but got {call_counts.get('msg1', 0)}"
+ )
+ assert call_counts["msg1"] >= 1, "Expected at least one call"
+
+ @pytest.mark.asyncio
+ async def test_compaction_and_futures_resolution(self):
+ """
+ Verify that even when compacted, all futures resolve to the result of the LAST execution.
+ Logic ported from verify_limiter_v2.py
+ """
+ os.environ["MESSAGING_RATE_LIMIT"] = "1"
+ os.environ["MESSAGING_RATE_WINDOW"] = "0.5"
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ call_counts = {}
+ msg_id = "test_msg_hang"
+
+ async def mock_edit(mid, content):
+ call_counts[mid] = call_counts.get(mid, 0) + 1
+ await asyncio.sleep(0.05)
+ return f"result_{content}"
+
+ async def task(i):
+ return await limiter.enqueue(
+ lambda i=i: mock_edit(msg_id, f"v{i}"), dedup_key=f"edit:{msg_id}"
+ )
+
+ start_time = time.time()
+
+ # Enqueue 3 tasks concurrently
+ results = await asyncio.gather(task(1), task(2), task(3))
+
+ duration = time.time() - start_time
+
+ # All results should be the LAST one executed
+ for res in results:
+ assert res == "result_v3", f"Expected result_v3, got {res}"
+
+ # Should be reasonably fast
+ assert duration < 2.0, "Execution took too long"
+
+ # Calls should be compacted
+ assert call_counts[msg_id] <= 2, f"Too many actual calls: {call_counts[msg_id]}"
+
+ @pytest.mark.asyncio
+ async def test_flood_wait_handling(self):
+ """Test that FloodWait exceptions pause the worker."""
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ # Mock exception with .seconds attribute
+ class FloodWait(Exception):
+ def __init__(self, seconds):
+ self.seconds = seconds
+ super().__init__(f"Flood wait {seconds}s")
+
+ call_count = 0
+
+ async def mock_fail():
+ nonlocal call_count
+ call_count += 1
+ raise FloodWait(1) # 1 second wait
+
+ async def mock_success():
+ nonlocal call_count
+ call_count += 1
+ return "success"
+
+ # First call fails and triggers pause
+ with contextlib.suppress(Exception):
+ await limiter.enqueue(mock_fail, dedup_key="key1")
+
+ assert limiter._paused_until > 0
+
+ # Enqueue success, it should wait
+ start = time.time()
+ await limiter.enqueue(mock_success, dedup_key="key2")
+ duration = time.time() - start
+
+ # Should have waited at least ~1s
+ assert duration >= 0.9, (
+ f"Should have waited for FloodWait, but took {duration:.2f}s"
+ )
+ assert call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_flood_wait_retry_after_parsing(self):
+ """Error message with 'retry after N' parses the wait seconds."""
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ async def mock_flood():
+ raise Exception("Flood wait: retry after 2 seconds")
+
+ with contextlib.suppress(Exception):
+ await limiter.enqueue(mock_flood, dedup_key="retry_parse")
+
+ # Should have parsed "after 2" -> 2 seconds
+ assert limiter._paused_until > 0
+
+ @pytest.mark.asyncio
+ async def test_non_flood_exception_no_pause(self):
+ """Non-flood exception doesn't trigger pause."""
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ async def mock_error():
+ raise ValueError("some regular error")
+
+ with contextlib.suppress(ValueError):
+ await limiter.enqueue(mock_error, dedup_key="non_flood")
+
+ # Should NOT have paused since it's not a flood error
+ assert limiter._paused_until == 0
+
+ @pytest.mark.asyncio
+ async def test_flood_with_seconds_attribute(self):
+ """Exception with .seconds attribute uses that value for pause."""
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ class FloodWaitCustom(Exception):
+ def __init__(self):
+ self.seconds = 2
+ super().__init__("Flood wait custom")
+
+ async def mock_flood():
+ raise FloodWaitCustom()
+
+ with contextlib.suppress(Exception):
+ await limiter.enqueue(mock_flood, dedup_key="flood_sec")
+
+ assert limiter._paused_until > 0
+
+ @pytest.mark.asyncio
+ async def test_proactive_strict_sliding_window(self):
+ """
+ Proactive limiter should enforce a strict sliding window:
+ for any i, t[i+rate_limit] - t[i] >= rate_window (within tolerance).
+ """
+ os.environ["MESSAGING_RATE_LIMIT"] = "2"
+ os.environ["MESSAGING_RATE_WINDOW"] = "0.5"
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ async def acquire(i: int) -> float:
+ async def _do() -> float:
+ return time.monotonic()
+
+ return await limiter.enqueue(_do, dedup_key=f"strict:{i}")
+
+ acquired = await asyncio.gather(*(acquire(i) for i in range(5)))
+ acquired.sort()
+
+ rate_limit = 2
+ rate_window = 0.5
+ tolerance = 0.05
+ for i in range(len(acquired) - rate_limit):
+ assert acquired[i + rate_limit] - acquired[i] >= rate_window - tolerance, (
+ f"Sliding window violated at i={i}: "
+ f"dt={acquired[i + rate_limit] - acquired[i]:.3f}s"
+ )
+
+ @pytest.mark.asyncio
+ async def test_compaction_last_task_fails_all_futures_get_exception(self):
+ """When compacted task's last func fails, all futures get the exception."""
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ async def ok_task():
+ return "ok"
+
+ async def fail_task():
+ raise RuntimeError("last task failed")
+
+ future1 = asyncio.create_task(limiter.enqueue(ok_task, dedup_key="fail_key"))
+ future2 = asyncio.create_task(limiter.enqueue(fail_task, dedup_key="fail_key"))
+
+ with pytest.raises(RuntimeError, match="last task failed"):
+ await future1
+ with pytest.raises(RuntimeError, match="last task failed"):
+ await future2
+
+ @pytest.mark.asyncio
+ async def test_fire_and_forget_failure_logged(self, caplog):
+ """fire_and_forget with failing task logs error and does not re-raise."""
+ MessagingRateLimiter._instance = None
+ limiter = await MessagingRateLimiter.get_instance()
+
+ async def fail_task():
+ raise ValueError("fire_and_forget failed")
+
+ limiter.fire_and_forget(fail_task, dedup_key="fire_fail")
+ await asyncio.sleep(1.5)
+
+ assert any("fire_and_forget failed" in str(r) for r in caplog.records)
diff --git a/Claude_Code/tests/messaging/test_messaging.py b/Claude_Code/tests/messaging/test_messaging.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e0190470533996cb3426dd1811fe295a1ac42b6
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_messaging.py
@@ -0,0 +1,197 @@
+"""Tests for messaging/ module."""
+
+import json
+from unittest.mock import patch
+
+import pytest
+
+# --- Existing Tests ---
+
+
+class TestMessagingModels:
+ """Test messaging models."""
+
+ def test_incoming_message_creation(self):
+ """Test IncomingMessage dataclass."""
+ from messaging.models import IncomingMessage
+
+ msg = IncomingMessage(
+ text="Hello",
+ chat_id="123",
+ user_id="456",
+ message_id="789",
+ platform="telegram",
+ )
+ assert msg.text == "Hello"
+ assert msg.chat_id == "123"
+ assert msg.platform == "telegram"
+ assert msg.is_reply() is False
+
+ def test_incoming_message_with_reply(self):
+ """Test IncomingMessage as a reply."""
+ from messaging.models import IncomingMessage
+
+ msg = IncomingMessage(
+ text="Reply text",
+ chat_id="123",
+ user_id="456",
+ message_id="789",
+ platform="discord",
+ reply_to_message_id="100",
+ )
+ assert msg.is_reply() is True
+ assert msg.reply_to_message_id == "100"
+
+
+class TestMessagingBase:
+ """Test MessagingPlatform ABC."""
+
+ def test_platform_is_abstract(self):
+ """Verify MessagingPlatform cannot be instantiated."""
+ from messaging.platforms.base import MessagingPlatform
+
+ with pytest.raises(TypeError):
+ MessagingPlatform()
+
+
+class TestSessionStore:
+ """Test SessionStore."""
+
+ def test_session_store_init(self, tmp_path):
+ """Test SessionStore initialization."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+ assert store._trees == {}
+
+ # --- Tree Tests ---
+
+ def test_save_and_get_tree(self, tmp_path):
+ """Test saving and retrieving trees."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+
+ tree_data = {
+ "root": "r1",
+ "nodes": {"r1": {"content": "root"}, "n1": {"content": "child"}},
+ }
+ store.save_tree("r1", tree_data)
+
+ loaded = store.get_tree("r1")
+ assert loaded == tree_data
+
+ # Verify node mapping
+ node_map = store.get_node_mapping()
+ assert node_map["r1"] == "r1"
+ assert node_map["n1"] == "r1"
+
+ def test_register_node(self, tmp_path):
+ """Test manual node registration."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+ store.register_node("n_manual", "r_manual")
+ assert store.get_node_mapping()["n_manual"] == "r_manual"
+
+ # --- Persistence & Edge Cases ---
+
+ def test_load_existing_file_with_trees(self, tmp_path):
+ """Test loading file with trees (legacy sessions ignored)."""
+ from messaging.session import SessionStore
+
+ data = {
+ "sessions": {},
+ "trees": {"r1": {"root_id": "r1", "nodes": {"r1": {}}}},
+ "node_to_tree": {"r1": "r1"},
+ "message_log": {},
+ }
+
+ p = tmp_path / "sessions.json"
+ with open(p, "w") as f:
+ json.dump(data, f)
+
+ store = SessionStore(storage_path=str(p))
+ assert store.get_tree("r1") is not None
+
+ def test_load_corrupt_file(self, tmp_path):
+ """Test loading corrupt/invalid json file."""
+ p = tmp_path / "sessions.json"
+ with open(p, "w") as f:
+ f.write("{invalid json")
+
+ from messaging.session import SessionStore
+
+ # Should log error and start empty, avoiding crash
+ store = SessionStore(storage_path=str(p))
+ assert store._trees == {}
+
+ def test_save_error_handling(self, tmp_path):
+ """Test error during save."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+ store.save_tree("r1", {"root_id": "r1", "nodes": {"r1": {}}})
+
+ # Mock open to raise exception
+ with patch("builtins.open", side_effect=OSError("Disk full")):
+ store.save_tree("r2", {"root_id": "r2", "nodes": {"r2": {}}})
+
+ # Should log error but not crash. Tree should be in memory.
+ assert "r2" in store._trees
+
+
+class TestTreeQueueManager:
+ """Test TreeQueueManager."""
+
+ def test_tree_queue_manager_init(self):
+ """Test TreeQueueManager initialization."""
+ from messaging.trees.queue_manager import TreeQueueManager
+
+ mgr = TreeQueueManager()
+ assert mgr.get_tree_count() == 0
+
+ def test_tree_not_busy_initially(self):
+ """Test tree is not busy when no messages."""
+ from messaging.trees.queue_manager import TreeQueueManager
+
+ mgr = TreeQueueManager()
+ assert mgr.is_tree_busy("nonexistent") is False
+
+ def test_get_queue_size_empty(self):
+ """Test queue size is 0 for non-existent node."""
+ from messaging.trees.queue_manager import TreeQueueManager
+
+ mgr = TreeQueueManager()
+ assert mgr.get_queue_size("nonexistent") == 0
+
+ @pytest.mark.asyncio
+ async def test_create_tree_and_enqueue(self):
+ """Test creating a tree and enqueueing."""
+ from messaging.models import IncomingMessage
+ from messaging.trees.queue_manager import TreeQueueManager
+
+ mgr = TreeQueueManager()
+ processed = []
+
+ async def processor(node_id, node):
+ processed.append(node_id)
+
+ incoming = IncomingMessage(
+ text="test", chat_id="1", user_id="1", message_id="1", platform="test"
+ )
+
+ await mgr.create_tree("1", incoming, "status_1")
+ was_queued = await mgr.enqueue("1", processor)
+
+ # First message should process immediately, not queue
+ assert was_queued is False
+
+ @pytest.mark.asyncio
+ async def test_cancel_tree_empty(self):
+ """Test cancelling non-existent tree."""
+ from messaging.trees.queue_manager import TreeQueueManager
+
+ mgr = TreeQueueManager()
+ cancelled = await mgr.cancel_tree("nonexistent")
+ assert cancelled == []
diff --git a/Claude_Code/tests/messaging/test_messaging_factory.py b/Claude_Code/tests/messaging/test_messaging_factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..9078e65807e52ec97b99be0b39ad26bdbb4e4324
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_messaging_factory.py
@@ -0,0 +1,77 @@
+"""Tests for messaging platform factory."""
+
+from unittest.mock import MagicMock, patch
+
+from messaging.platforms.factory import create_messaging_platform
+
+
+class TestCreateMessagingPlatform:
+ """Tests for create_messaging_platform factory function."""
+
+ def test_telegram_with_token(self):
+ """Create Telegram platform when bot_token is provided."""
+ mock_platform = MagicMock()
+ with (
+ patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True),
+ patch(
+ "messaging.platforms.telegram.TelegramPlatform",
+ return_value=mock_platform,
+ ),
+ ):
+ result = create_messaging_platform(
+ "telegram",
+ bot_token="test_token",
+ allowed_user_id="12345",
+ )
+
+ assert result is mock_platform
+
+ def test_telegram_without_token(self):
+ """Return None when no bot_token for Telegram."""
+ result = create_messaging_platform("telegram")
+ assert result is None
+
+ def test_telegram_empty_token(self):
+ """Return None when bot_token is empty string."""
+ result = create_messaging_platform("telegram", bot_token="")
+ assert result is None
+
+ def test_discord_with_token(self):
+ """Create Discord platform when discord_bot_token is provided."""
+ mock_platform = MagicMock()
+ with (
+ patch("messaging.platforms.discord.DISCORD_AVAILABLE", True),
+ patch(
+ "messaging.platforms.discord.DiscordPlatform",
+ return_value=mock_platform,
+ ),
+ ):
+ result = create_messaging_platform(
+ "discord",
+ discord_bot_token="test_token",
+ allowed_discord_channels="123,456",
+ )
+
+ assert result is mock_platform
+
+ def test_discord_without_token(self):
+ """Return None when no discord_bot_token for Discord."""
+ result = create_messaging_platform("discord")
+ assert result is None
+
+ def test_discord_empty_token(self):
+ """Return None when discord_bot_token is empty string."""
+ result = create_messaging_platform(
+ "discord", discord_bot_token="", allowed_discord_channels="123"
+ )
+ assert result is None
+
+ def test_unknown_platform(self):
+ """Return None for unknown platform types."""
+ result = create_messaging_platform("slack")
+ assert result is None
+
+ def test_unknown_platform_with_kwargs(self):
+ """Return None for unknown platform even with kwargs."""
+ result = create_messaging_platform("slack", bot_token="token")
+ assert result is None
diff --git a/Claude_Code/tests/messaging/test_reliability.py b/Claude_Code/tests/messaging/test_reliability.py
new file mode 100644
index 0000000000000000000000000000000000000000..87652132a4c13442d89cec08bc6b85ba53721731
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_reliability.py
@@ -0,0 +1,138 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from telegram.error import NetworkError, RetryAfter, TelegramError
+
+from messaging.platforms.telegram import TelegramPlatform
+
+
+@pytest.fixture
+def telegram_platform():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ platform = TelegramPlatform(bot_token="test_token", allowed_user_id="12345")
+ return platform
+
+
+@pytest.mark.asyncio
+async def test_telegram_retry_on_network_error(telegram_platform):
+ mock_bot = AsyncMock()
+ mock_msg = MagicMock()
+ mock_msg.message_id = 999
+
+ # Fail twice, then succeed
+ mock_bot.send_message.side_effect = [
+ NetworkError("Connection failed"),
+ NetworkError("Connection failed"),
+ mock_msg,
+ ]
+
+ telegram_platform._application = MagicMock()
+ telegram_platform._application.bot = mock_bot
+
+ # We need to patch asyncio.sleep to speed up the test
+ with patch("asyncio.sleep", AsyncMock()) as mock_sleep:
+ msg_id = await telegram_platform.send_message("chat_1", "hello")
+
+ assert msg_id == "999"
+ assert mock_bot.send_message.call_count == 3
+ assert mock_sleep.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_telegram_retry_on_retry_after(telegram_platform):
+ mock_bot = AsyncMock()
+ mock_msg = MagicMock()
+ mock_msg.message_id = 1000
+
+ # Fail with RetryAfter, then succeed
+ mock_bot.send_message.side_effect = [RetryAfter(retry_after=5), mock_msg]
+
+ telegram_platform._application = MagicMock()
+ telegram_platform._application.bot = mock_bot
+
+ with patch("asyncio.sleep", AsyncMock()) as mock_sleep:
+ msg_id = await telegram_platform.send_message("chat_1", "hello")
+
+ assert msg_id == "1000"
+ assert mock_bot.send_message.call_count == 2
+ mock_sleep.assert_called_with(5)
+
+
+@pytest.mark.asyncio
+async def test_telegram_no_retry_on_bad_request(telegram_platform):
+ mock_bot = AsyncMock()
+
+ # Fail with generic TelegramError (should not retry unless specific conditions met)
+ mock_bot.send_message.side_effect = TelegramError("Bad Request: some error")
+
+ telegram_platform._application = MagicMock()
+ telegram_platform._application.bot = mock_bot
+
+ with pytest.raises(TelegramError):
+ await telegram_platform.send_message("chat_1", "hello")
+
+ assert mock_bot.send_message.call_count == 1
+
+
+def test_handler_build_message_hardening():
+ # Formatting hardening now lives in TranscriptBuffer rendering.
+ from messaging.rendering.telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+ )
+ from messaging.transcript import RenderCtx, TranscriptBuffer
+
+ ctx = RenderCtx(
+ bold=mdv2_bold,
+ code_inline=mdv2_code_inline,
+ escape_code=escape_md_v2_code,
+ escape_text=escape_md_v2,
+ render_markdown=render_markdown_to_mdv2,
+ )
+
+ # Case 1: Empty transcript + no status => empty string.
+ t = TranscriptBuffer()
+ msg = t.render(ctx, limit_chars=3900, status=None)
+ assert msg == ""
+
+ # Case 2: Truncation with code block closing and status preserved.
+ t.apply({"type": "thinking_chunk", "text": ("thought " * 200)})
+ t.apply({"type": "text_chunk", "text": ("This is a very long message. " * 300)})
+
+ msg = t.render(ctx, limit_chars=3900, status="Finishing...")
+
+ assert len(msg) <= 4096
+ assert "Finishing..." in msg
+ if "```" in msg:
+ assert msg.count("```") % 2 == 0
+
+
+def test_render_output_never_exceeds_4096():
+ """Transcript render with various status lengths never exceeds Telegram 4096 limit."""
+ from messaging.rendering.telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+ )
+ from messaging.transcript import RenderCtx, TranscriptBuffer
+
+ ctx = RenderCtx(
+ bold=mdv2_bold,
+ code_inline=mdv2_code_inline,
+ escape_code=escape_md_v2_code,
+ escape_text=escape_md_v2,
+ render_markdown=render_markdown_to_mdv2,
+ )
+
+ t = TranscriptBuffer()
+ t.apply({"type": "thinking_chunk", "text": "x" * 500})
+ t.apply({"type": "text_chunk", "text": "y" * 3500})
+
+ for status in [None, "Done", "✅ *Complete*", "A" * 100]:
+ msg = t.render(ctx, limit_chars=3900, status=status)
+ assert len(msg) <= 4096, f"status={status!r} produced len={len(msg)}"
diff --git a/Claude_Code/tests/messaging/test_restart_reply_restore.py b/Claude_Code/tests/messaging/test_restart_reply_restore.py
new file mode 100644
index 0000000000000000000000000000000000000000..cef1c6f240398450af9d51aba6dc79bb3685dc2d
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_restart_reply_restore.py
@@ -0,0 +1,122 @@
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from messaging.handler import ClaudeMessageHandler
+from messaging.models import IncomingMessage
+from messaging.session import SessionStore
+from messaging.trees.queue_manager import TreeQueueManager
+
+
+@pytest.mark.asyncio
+async def test_reply_to_old_status_message_after_restore_routes_to_parent(
+ tmp_path, mock_platform, mock_cli_manager
+):
+ # Build a persisted tree with a root node A and a bot status message id.
+ store_path = tmp_path / "sessions.json"
+ store = SessionStore(storage_path=str(store_path))
+
+ handler1 = ClaudeMessageHandler(mock_platform, mock_cli_manager, store)
+ a_incoming = IncomingMessage(
+ text="A",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="A",
+ platform="telegram",
+ )
+ tree = await handler1.tree_queue.create_tree(
+ "A", a_incoming, status_message_id="status_A"
+ )
+ handler1.tree_queue.register_node("status_A", tree.root_id)
+ store.register_node("status_A", tree.root_id)
+ store.save_tree(tree.root_id, tree.to_dict())
+ store.flush_pending_save()
+
+ # "Restart": new store instance loads from disk, and we restore TreeQueueManager.
+ store2 = SessionStore(storage_path=str(store_path))
+ handler2 = ClaudeMessageHandler(mock_platform, mock_cli_manager, store2)
+ handler2.replace_tree_queue(
+ TreeQueueManager.from_dict(
+ {
+ "trees": store2.get_all_trees(),
+ "node_to_tree": store2.get_node_mapping(),
+ },
+ queue_update_callback=handler2.update_queue_positions,
+ node_started_callback=handler2.mark_node_processing,
+ )
+ )
+
+ # Prevent background task scheduling; we only want to validate routing/tree mutation.
+ mock_platform.queue_send_message = AsyncMock(return_value="status_reply")
+
+ reply = IncomingMessage(
+ text="R1",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="R1",
+ platform="telegram",
+ reply_to_message_id="status_A",
+ )
+
+ with patch.object(handler2.tree_queue, "enqueue", AsyncMock(return_value=False)):
+ await handler2.handle_message(reply)
+
+ restored_tree = handler2.tree_queue.get_tree_for_node("A")
+ assert restored_tree is not None
+ node_r1 = restored_tree.get_node("R1")
+ assert node_r1 is not None
+ assert node_r1.parent_id == "A"
+
+
+@pytest.mark.asyncio
+async def test_reply_to_old_status_message_without_mapping_creates_new_conversation(
+ tmp_path, mock_platform, mock_cli_manager
+):
+ store_path = tmp_path / "sessions.json"
+ store = SessionStore(storage_path=str(store_path))
+
+ handler1 = ClaudeMessageHandler(mock_platform, mock_cli_manager, store)
+ a_incoming = IncomingMessage(
+ text="A",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="A",
+ platform="telegram",
+ )
+ tree = await handler1.tree_queue.create_tree(
+ "A", a_incoming, status_message_id="status_A"
+ )
+ # Intentionally do NOT register "status_A" mapping.
+ store.save_tree(tree.root_id, tree.to_dict())
+ store.flush_pending_save()
+
+ store2 = SessionStore(storage_path=str(store_path))
+ handler2 = ClaudeMessageHandler(mock_platform, mock_cli_manager, store2)
+ handler2.replace_tree_queue(
+ TreeQueueManager.from_dict(
+ {
+ "trees": store2.get_all_trees(),
+ "node_to_tree": store2.get_node_mapping(),
+ },
+ queue_update_callback=handler2.update_queue_positions,
+ node_started_callback=handler2.mark_node_processing,
+ )
+ )
+ mock_platform.queue_send_message = AsyncMock(return_value="status_reply")
+
+ reply = IncomingMessage(
+ text="R1",
+ chat_id="chat_1",
+ user_id="user_1",
+ message_id="R1",
+ platform="telegram",
+ reply_to_message_id="status_A",
+ )
+
+ with patch.object(handler2.tree_queue, "enqueue", AsyncMock(return_value=False)):
+ await handler2.handle_message(reply)
+
+ # Since the mapping is missing, this should be treated as a new conversation.
+ new_tree = handler2.tree_queue.get_tree_for_node("R1")
+ assert new_tree is not None
+ assert new_tree.root_id == "R1"
diff --git a/Claude_Code/tests/messaging/test_robust_formatting.py b/Claude_Code/tests/messaging/test_robust_formatting.py
new file mode 100644
index 0000000000000000000000000000000000000000..a431dabb86c7c4567f4ebd0da5560e97b9bd4c8c
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_robust_formatting.py
@@ -0,0 +1,91 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from messaging.rendering.telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+)
+from messaging.transcript import RenderCtx, TranscriptBuffer
+
+
+@pytest.fixture
+def handler():
+ platform = MagicMock()
+ cli = MagicMock()
+ store = MagicMock()
+ return (platform, cli, store)
+
+
+def _ctx() -> RenderCtx:
+ return RenderCtx(
+ bold=mdv2_bold,
+ code_inline=mdv2_code_inline,
+ escape_code=escape_md_v2_code,
+ escape_text=escape_md_v2,
+ render_markdown=render_markdown_to_mdv2,
+ )
+
+
+def test_truncation_closes_code_blocks(handler):
+ """Verify that truncation correctly closes open code blocks."""
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "thinking_chunk",
+ "text": "Starting some long thinking process that will definitely cause truncation later on...",
+ }
+ )
+ t.apply(
+ {
+ "type": "text_chunk",
+ "text": "```python\ndef very_long_function():\n # " + ("A" * 4000),
+ }
+ )
+
+ msg = t.render(_ctx(), limit_chars=3900, status="✅ *Complete*")
+
+ # The backtick count must be even to be a valid block.
+ assert msg.count("```") % 2 == 0
+ assert msg.endswith("```") or "✅ *Complete*" in msg.split("```")[-1]
+
+
+def test_truncation_preserves_status(handler):
+ """Verify that status is still appended after truncation."""
+ status = "READY_STATUS"
+ t = TranscriptBuffer()
+ t.apply({"type": "thinking_chunk", "text": "Thinking..."})
+ t.apply({"type": "text_chunk", "text": "A" * 5000})
+ msg = t.render(_ctx(), limit_chars=3900, status=status)
+
+ assert status in msg
+
+
+def test_empty_components_with_status(handler):
+ """Verify message building with just a status."""
+ status = "Simple Status"
+ t = TranscriptBuffer()
+ msg = t.render(_ctx(), limit_chars=3900, status=status)
+ assert msg == "\n\nSimple Status"
+
+
+def test_render_markdown_unclosed_markdown():
+ """Malformed markdown (e.g. unclosed *) does not crash and produces acceptable output."""
+ from messaging.rendering.telegram_markdown import render_markdown_to_mdv2
+
+ md = "*bold without close"
+ out = render_markdown_to_mdv2(md)
+ assert out is not None
+ assert "bold" in out
+
+
+def test_escape_md_v2_unicode_emoji():
+ """Unicode and emoji pass through correctly (no special char escaping needed)."""
+ from messaging.rendering.telegram_markdown import escape_md_v2, escape_md_v2_code
+
+ text = "Hello 世界 🎉 café"
+ assert escape_md_v2(text) == text
+ assert escape_md_v2_code(text) == text
diff --git a/Claude_Code/tests/messaging/test_session_store_edge_cases.py b/Claude_Code/tests/messaging/test_session_store_edge_cases.py
new file mode 100644
index 0000000000000000000000000000000000000000..31683d494cf2129a1781c23c9fb9ad2b7cc3403a
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_session_store_edge_cases.py
@@ -0,0 +1,154 @@
+"""Edge case tests for messaging/session.py SessionStore."""
+
+import json
+from unittest.mock import patch
+
+import pytest
+
+from messaging.session import SessionStore
+
+
+@pytest.fixture
+def tmp_store(tmp_path):
+ """Create a SessionStore using a temp file."""
+ path = str(tmp_path / "sessions.json")
+ return SessionStore(storage_path=path)
+
+
+class TestSessionStoreLoadEdgeCases:
+ """Tests for loading corrupted/malformed data."""
+
+ def test_load_corrupted_json(self, tmp_path):
+ """Corrupted JSON file is handled gracefully (logs error, starts empty)."""
+ path = str(tmp_path / "sessions.json")
+ with open(path, "w") as f:
+ f.write("{invalid json")
+
+ store = SessionStore(storage_path=path)
+ assert len(store._trees) == 0
+
+ def test_load_truncated_json(self, tmp_path):
+ """Truncated JSON file is handled gracefully."""
+ path = str(tmp_path / "sessions.json")
+ with open(path, "w") as f:
+ f.write('{"sessions": {"s1": {"session_id": "s1"')
+
+ store = SessionStore(storage_path=path)
+ assert len(store._trees) == 0
+
+ def test_load_empty_file(self, tmp_path):
+ """Empty file is handled gracefully."""
+ path = str(tmp_path / "sessions.json")
+ with open(path, "w") as f:
+ f.write("")
+
+ store = SessionStore(storage_path=path)
+ assert len(store._trees) == 0
+
+ def test_load_nonexistent_file(self, tmp_path):
+ """Non-existent file starts with empty state."""
+ path = str(tmp_path / "nonexistent.json")
+ store = SessionStore(storage_path=path)
+ assert len(store._trees) == 0
+
+ def test_load_legacy_sessions_ignored(self, tmp_path):
+ """Legacy sessions in file are ignored; trees and message_log load."""
+ path = str(tmp_path / "sessions.json")
+ data = {
+ "sessions": {
+ "s1": {
+ "session_id": "s1",
+ "chat_id": 12345,
+ "initial_msg_id": 100,
+ "last_msg_id": 200,
+ "platform": "telegram",
+ "created_at": "2025-01-01T00:00:00+00:00",
+ "updated_at": "2025-01-01T00:00:00+00:00",
+ }
+ },
+ "trees": {"r1": {"root_id": "r1", "nodes": {"r1": {}}}},
+ "node_to_tree": {"r1": "r1"},
+ "message_log": {},
+ }
+ with open(path, "w") as f:
+ json.dump(data, f)
+
+ store = SessionStore(storage_path=path)
+ assert store.get_tree("r1") is not None
+
+
+class TestSessionStoreSaveEdgeCases:
+ """Tests for save failure handling."""
+
+ def test_save_io_error_handled(self, tmp_store):
+ """Write failure in _write_data() raises (callers handle the error)."""
+ tmp_store.save_tree("r1", {"root_id": "r1", "nodes": {"r1": {}}})
+ with (
+ patch("builtins.open", side_effect=OSError("disk full")),
+ pytest.raises(OSError),
+ ):
+ tmp_store._write_data(tmp_store._snapshot())
+
+
+class TestSessionStoreClearAll:
+ def test_clear_all_wipes_state_and_persists(self, tmp_path):
+ path = str(tmp_path / "sessions.json")
+ store = SessionStore(storage_path=path)
+
+ store.save_tree(
+ "root1",
+ {
+ "root_id": "root1",
+ "nodes": {
+ "root1": {
+ "node_id": "root1",
+ "incoming": {
+ "text": "hello",
+ "chat_id": "c1",
+ "user_id": "u1",
+ "message_id": "m1",
+ "platform": "telegram",
+ "reply_to_message_id": None,
+ "username": None,
+ },
+ "status_message_id": "status1",
+ "state": "pending",
+ "parent_id": None,
+ "session_id": None,
+ "children_ids": [],
+ "created_at": "2025-01-01T00:00:00+00:00",
+ "completed_at": None,
+ "error_message": None,
+ }
+ },
+ },
+ )
+
+ store.clear_all()
+
+ assert store.get_all_trees() == {}
+ assert store.get_node_mapping() == {}
+
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ assert data["trees"] == {}
+ assert data["node_to_tree"] == {}
+ assert data["message_log"] == {}
+
+ store2 = SessionStore(storage_path=path)
+ assert len(store2._trees) == 0
+
+ def test_message_log_persists_and_dedups(self, tmp_path):
+ path = str(tmp_path / "sessions.json")
+ store = SessionStore(storage_path=path)
+
+ store.record_message_id("telegram", "c1", "1", direction="in", kind="command")
+ store.record_message_id("telegram", "c1", "2", direction="out", kind="command")
+ store.record_message_id("telegram", "c1", "2", direction="out", kind="command")
+
+ ids = store.get_message_ids_for_chat("telegram", "c1")
+ assert ids == ["1", "2"]
+
+ store.flush_pending_save()
+ store2 = SessionStore(storage_path=path)
+ assert store2.get_message_ids_for_chat("telegram", "c1") == ["1", "2"]
diff --git a/Claude_Code/tests/messaging/test_telegram.py b/Claude_Code/tests/messaging/test_telegram.py
new file mode 100644
index 0000000000000000000000000000000000000000..84454845e6e4dcccc2e218bdbaace0b63f054d83
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_telegram.py
@@ -0,0 +1,115 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from messaging.platforms.telegram import TelegramPlatform
+
+
+@pytest.fixture
+def telegram_platform():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ platform = TelegramPlatform(bot_token="test_token", allowed_user_id="12345")
+ return platform
+
+
+def test_telegram_platform_init_no_token():
+ with patch.dict("os.environ", {}, clear=True):
+ platform = TelegramPlatform(bot_token=None)
+ assert platform.bot_token is None
+
+
+@pytest.mark.asyncio
+async def test_telegram_platform_start_success(telegram_platform):
+ with patch("telegram.ext.Application.builder") as mock_builder:
+ mock_app = MagicMock()
+ mock_app.initialize = AsyncMock()
+ mock_app.start = AsyncMock()
+ mock_app.updater.start_polling = AsyncMock()
+
+ mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app
+
+ # Mock MessagingRateLimiter
+ with patch("messaging.limiter.MessagingRateLimiter.get_instance", AsyncMock()):
+ await telegram_platform.start()
+
+ assert telegram_platform._connected is True
+ mock_app.initialize.assert_called_once()
+ mock_app.start.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_telegram_platform_send_message_success(telegram_platform):
+ mock_bot = AsyncMock()
+ mock_msg = MagicMock()
+ mock_msg.message_id = 999
+ mock_bot.send_message.return_value = mock_msg
+
+ telegram_platform._application = MagicMock()
+ telegram_platform._application.bot = mock_bot
+
+ msg_id = await telegram_platform.send_message("chat_1", "hello")
+
+ assert msg_id == "999"
+ mock_bot.send_message.assert_called_once_with(
+ chat_id="chat_1",
+ text="hello",
+ reply_to_message_id=None,
+ parse_mode="MarkdownV2",
+ )
+
+
+@pytest.mark.asyncio
+async def test_telegram_platform_edit_message_success(telegram_platform):
+ mock_bot = AsyncMock()
+ telegram_platform._application = MagicMock()
+ telegram_platform._application.bot = mock_bot
+
+ await telegram_platform.edit_message("chat_1", "999", "new text")
+
+ mock_bot.edit_message_text.assert_called_once_with(
+ chat_id="chat_1", message_id=999, text="new text", parse_mode="MarkdownV2"
+ )
+
+
+@pytest.mark.asyncio
+async def test_telegram_platform_queue_send_message(telegram_platform):
+ mock_limiter = AsyncMock()
+ telegram_platform._limiter = mock_limiter
+
+ await telegram_platform.queue_send_message("chat_1", "hello", fire_and_forget=False)
+
+ mock_limiter.enqueue.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_on_telegram_message_authorized(telegram_platform):
+ handler = AsyncMock()
+ telegram_platform.on_message(handler)
+
+ mock_update = MagicMock()
+ mock_update.message.text = "hello"
+ mock_update.message.message_id = 1
+ mock_update.effective_user.id = 12345
+ mock_update.effective_chat.id = 6789
+ mock_update.message.reply_to_message = None
+
+ await telegram_platform._on_telegram_message(mock_update, MagicMock())
+
+ handler.assert_called_once()
+ incoming = handler.call_args[0][0]
+ assert incoming.text == "hello"
+ assert incoming.user_id == "12345"
+
+
+@pytest.mark.asyncio
+async def test_on_telegram_message_unauthorized(telegram_platform):
+ handler = AsyncMock()
+ telegram_platform.on_message(handler)
+
+ mock_update = MagicMock()
+ mock_update.message.text = "hello"
+ mock_update.effective_user.id = 99999 # Unauthorized
+
+ await telegram_platform._on_telegram_message(mock_update, MagicMock())
+
+ handler.assert_not_called()
diff --git a/Claude_Code/tests/messaging/test_telegram_edge_cases.py b/Claude_Code/tests/messaging/test_telegram_edge_cases.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f9ace6906c0c444892a592020b8c2b783de5fb4
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_telegram_edge_cases.py
@@ -0,0 +1,311 @@
+import asyncio
+from datetime import timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from telegram.error import NetworkError, RetryAfter, TelegramError
+
+
+def test_telegram_platform_init_raises_when_dependency_missing():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", False):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ with pytest.raises(ImportError):
+ TelegramPlatform(bot_token="x")
+
+
+@pytest.mark.asyncio
+async def test_telegram_platform_start_requires_token():
+ with (
+ patch.dict("os.environ", {}, clear=True),
+ patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True),
+ ):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token=None)
+ with pytest.raises(ValueError):
+ await platform.start()
+
+
+@pytest.mark.asyncio
+async def test_telegram_platform_stop_no_application_is_noop():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ platform._application = None
+ platform._connected = True
+ await platform.stop()
+ assert platform.is_connected is False
+
+
+@pytest.mark.asyncio
+async def test_with_retry_returns_none_when_message_not_modified_network_error():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+
+ async def _f():
+ raise NetworkError("Message is not modified")
+
+ assert await platform._with_retry(_f) is None
+
+
+@pytest.mark.asyncio
+async def test_with_retry_retries_network_error_then_succeeds(monkeypatch):
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+
+ monkeypatch.setattr(asyncio, "sleep", AsyncMock())
+
+ calls = {"n": 0}
+
+ async def _f():
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise NetworkError("temporary")
+ return "ok"
+
+ assert await platform._with_retry(_f) == "ok"
+ assert calls["n"] == 2
+
+
+@pytest.mark.asyncio
+async def test_with_retry_honors_retry_after_timedelta(monkeypatch):
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+
+ monkeypatch.setattr(asyncio, "sleep", AsyncMock())
+
+ calls = {"n": 0}
+
+ async def _f():
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise RetryAfter(retry_after=timedelta(seconds=0.01))
+ return "ok"
+
+ assert await platform._with_retry(_f) == "ok"
+ assert calls["n"] == 2
+
+
+@pytest.mark.asyncio
+async def test_with_retry_drops_parse_mode_on_markdown_entity_error():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+
+ calls = []
+
+ async def _f(parse_mode=None):
+ calls.append(parse_mode)
+ if len(calls) == 1:
+ raise TelegramError("Can't parse entities: bad markdown")
+ return "ok"
+
+ assert await platform._with_retry(_f, parse_mode="MarkdownV2") == "ok"
+ assert calls == ["MarkdownV2", None]
+
+
+@pytest.mark.asyncio
+async def test_queue_send_message_without_limiter_calls_send_message():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ platform._limiter = None
+ with patch.object(
+ platform, "send_message", new_callable=AsyncMock
+ ) as mock_send:
+ mock_send.return_value = "1"
+ assert await platform.queue_send_message("c", "t") == "1"
+ mock_send.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_queue_edit_message_without_limiter_calls_edit_message():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ platform._limiter = None
+ with patch.object(
+ platform, "edit_message", new_callable=AsyncMock
+ ) as mock_edit:
+ await platform.queue_edit_message("c", "1", "t")
+ mock_edit.assert_awaited_once()
+
+
+def test_fire_and_forget_non_coroutine_uses_ensure_future(monkeypatch):
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+
+ ef = MagicMock()
+ monkeypatch.setattr(asyncio, "ensure_future", ef)
+
+ platform.fire_and_forget(MagicMock())
+ ef.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_on_start_command_replies_and_forwards():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ with patch.object(
+ platform, "_on_telegram_message", new_callable=AsyncMock
+ ) as mock_msg:
+ update = MagicMock()
+ update.message.reply_text = AsyncMock()
+
+ await platform._on_start_command(update, MagicMock())
+ update.message.reply_text.assert_awaited_once()
+ mock_msg.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_on_telegram_message_handler_error_sends_error_message():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t", allowed_user_id="123")
+ with patch.object(
+ platform, "send_message", new_callable=AsyncMock
+ ) as mock_send:
+
+ async def _boom(_incoming):
+ raise RuntimeError("bad")
+
+ platform.on_message(_boom)
+
+ update = MagicMock()
+ update.message.text = "hello"
+ update.message.message_id = 7
+ update.message.reply_to_message = None
+ update.effective_user.id = 123
+ update.effective_chat.id = 456
+
+ await platform._on_telegram_message(update, MagicMock())
+ mock_send.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_telegram_start_retries_on_network_error(monkeypatch):
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="token", allowed_user_id=None)
+
+ monkeypatch.setattr(asyncio, "sleep", AsyncMock())
+
+ with (
+ patch("telegram.ext.Application.builder") as mock_builder,
+ patch("messaging.limiter.MessagingRateLimiter.get_instance", AsyncMock()),
+ ):
+ mock_app = MagicMock()
+ mock_app.initialize = AsyncMock(side_effect=[NetworkError("no"), None])
+ mock_app.start = AsyncMock()
+ mock_app.updater = None
+
+ mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app
+
+ await platform.start()
+ assert platform.is_connected is True
+
+
+@pytest.mark.asyncio
+async def test_edit_message_with_text_exceeding_4096_raises():
+ """edit_message with text > 4096 raises TelegramError (BadRequest)."""
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ platform._application = MagicMock()
+ platform._application.bot = AsyncMock()
+ platform._application.bot.edit_message_text = AsyncMock(
+ side_effect=TelegramError("Bad Request: message is too long")
+ )
+
+ with pytest.raises(TelegramError):
+ await platform.edit_message("c", "1", "x" * 5000)
+
+
+@pytest.mark.asyncio
+async def test_edit_message_empty_string():
+ """edit_message with empty string - Telegram accepts (no-op edit)."""
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ platform._application = MagicMock()
+ platform._application.bot = AsyncMock()
+ platform._application.bot.edit_message_text = AsyncMock()
+
+ await platform.edit_message("c", "1", "")
+ platform._application.bot.edit_message_text.assert_awaited_once_with(
+ chat_id="c", message_id=1, text="", parse_mode="MarkdownV2"
+ )
+
+
+@pytest.mark.asyncio
+async def test_send_message_empty_string():
+ """send_message with empty string - Telegram may reject; we pass through."""
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+ platform._application = MagicMock()
+ mock_msg = MagicMock()
+ mock_msg.message_id = 1
+ platform._application.bot = AsyncMock()
+ platform._application.bot.send_message = AsyncMock(return_value=mock_msg)
+
+ msg_id = await platform.send_message("c", "")
+ assert msg_id == "1"
+ platform._application.bot.send_message.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_on_telegram_message_non_text_update_ignored():
+ """Update with message.photo but no text returns early without calling handler."""
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t", allowed_user_id="123")
+ handler = AsyncMock()
+ platform.on_message(handler)
+
+ update = MagicMock()
+ update.message.text = None
+ update.message.photo = [MagicMock()]
+ update.message.message_id = 7
+ update.message.reply_to_message = None
+ update.effective_user.id = 123
+ update.effective_chat.id = 456
+
+ await platform._on_telegram_message(update, MagicMock())
+ handler.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_with_retry_message_not_found_returns_none():
+ """'message to edit not found' returns None without retry."""
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ from messaging.platforms.telegram import TelegramPlatform
+
+ platform = TelegramPlatform(bot_token="t")
+
+ async def _f():
+ raise TelegramError("message to edit not found")
+
+ result = await platform._with_retry(_f)
+ assert result is None
diff --git a/Claude_Code/tests/messaging/test_transcript.py b/Claude_Code/tests/messaging/test_transcript.py
new file mode 100644
index 0000000000000000000000000000000000000000..09ae79380d100b4e21e5e33669c02b16867c6518
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_transcript.py
@@ -0,0 +1,343 @@
+from unittest.mock import patch
+
+from messaging.rendering.telegram_markdown import (
+ escape_md_v2,
+ escape_md_v2_code,
+ mdv2_bold,
+ mdv2_code_inline,
+ render_markdown_to_mdv2,
+)
+from messaging.transcript import RenderCtx, TranscriptBuffer
+
+
+def _ctx() -> RenderCtx:
+ return RenderCtx(
+ bold=mdv2_bold,
+ code_inline=mdv2_code_inline,
+ escape_code=escape_md_v2_code,
+ escape_text=escape_md_v2,
+ render_markdown=render_markdown_to_mdv2,
+ thinking_tail_max=1000,
+ tool_input_tail_max=1200,
+ tool_output_tail_max=1600,
+ text_tail_max=2000,
+ )
+
+
+def test_transcript_order_thinking_tool_text():
+ t = TranscriptBuffer()
+ t.apply({"type": "thinking_chunk", "text": "think1"})
+ t.apply({"type": "tool_use", "id": "tool_1", "name": "ls", "input": {"path": "."}})
+ t.apply({"type": "text_chunk", "text": "done"})
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert out.find("think1") < out.find("Tool call:") < out.find("done")
+
+
+def test_transcript_subagent_suppresses_thinking_and_text_inside():
+ t = TranscriptBuffer()
+
+ # Enter subagent context (Task tool call).
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_1",
+ "name": "Task",
+ "input": {"description": "Fix bug"},
+ }
+ )
+
+ # These should be suppressed while inside subagent context.
+ t.apply({"type": "thinking_delta", "index": -1, "text": "secret"})
+ t.apply({"type": "text_chunk", "text": "visible?"})
+
+ # Tool activity should still show.
+ t.apply({"type": "tool_use", "id": "tool_2", "name": "ls", "input": {"path": "."}})
+ t.apply({"type": "tool_result", "tool_use_id": "tool_2", "content": "x"})
+
+ # Close subagent context (Task tool result).
+ t.apply({"type": "tool_result", "tool_use_id": "task_1", "content": "done"})
+
+ # Now text should show again.
+ t.apply({"type": "text_chunk", "text": "after"})
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert "Subagent:" in out
+ assert "secret" not in out
+ assert "visible?" not in out
+ # Only the current tool call should be shown (not the full history).
+ assert out.count("Tool call:") == 1
+ assert "\n 🛠" in out or out.startswith(" 🛠") or " 🛠" in out
+ assert "Tools used:" in out
+ assert "Tool calls:" in out
+ assert "after" in out
+
+
+def test_transcript_subagent_closes_on_whitespace_tool_ids():
+ t = TranscriptBuffer()
+
+ # Provider emitted a Task tool_use id with leading whitespace.
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": " functions.Task:0",
+ "name": "Task",
+ "input": {"description": "Outer"},
+ }
+ )
+
+ # Task completes, but tool_result references a trimmed id (or vice versa).
+ t.apply(
+ {"type": "tool_result", "tool_use_id": "functions.Task:0", "content": "done"}
+ )
+
+ # Next Task should be top-level, not nested under the previous subagent.
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "functions.Task:1",
+ "name": "Task",
+ "input": {"description": "Next"},
+ }
+ )
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert out.count("Subagent:") == 2
+ # If nesting is incorrect, the second subagent line will be indented under the first.
+ assert "\n 🤖 *Subagent:* `Next`" not in out
+
+
+def test_transcript_subagent_closes_on_task_result_id_suffix_match():
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_1",
+ "name": "Task",
+ "input": {"description": "Outer"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "task_1_result", "content": "done"})
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_2",
+ "name": "Task",
+ "input": {"description": "Next"},
+ }
+ )
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert out.count("Subagent:") == 2
+ assert "\n 🤖 *Subagent:* `Next`" not in out
+
+
+def test_transcript_unmatched_non_task_tool_result_does_not_pop_subagent():
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_1",
+ "name": "Task",
+ "input": {"description": "Outer"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "totally_unrelated", "content": "x"})
+
+ assert t._subagent_stack == ["task_1"]
+
+
+def test_transcript_sequential_tasks_mismatched_results_no_depth_drift():
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_1",
+ "name": "Task",
+ "input": {"description": "A"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "task_1_result", "content": "done"})
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_2",
+ "name": "Task",
+ "input": {"description": "B"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "task_2_result", "content": "done"})
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_3",
+ "name": "Task",
+ "input": {"description": "C"},
+ }
+ )
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert "🤖 *Subagent:* `A`\n 🤖 *Subagent:* `B`" not in out
+ assert "\n 🤖 *Subagent:* `C`" not in out
+ assert t._subagent_stack == ["task_3"]
+
+
+def test_transcript_synthetic_task_start_closes_on_functions_task_result_id():
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use_start",
+ "index": 0,
+ "id": "",
+ "name": "Task",
+ "input": {"description": "Outer"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "functions.Task:0", "content": "x"})
+ t.apply(
+ {
+ "type": "tool_use_start",
+ "index": 1,
+ "id": "",
+ "name": "Task",
+ "input": {"description": "Next"},
+ }
+ )
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert out.count("Subagent:") == 2
+ assert "\n 🤖 *Subagent:* `Next`" not in out
+
+
+def test_transcript_synthetic_task_not_closed_by_unknown_non_task_result_id():
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use_start",
+ "index": 0,
+ "id": "",
+ "name": "Task",
+ "input": {"description": "Outer"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "call_deadbeef", "content": "x"})
+
+ assert t._subagent_stack == ["__task_1"]
+
+
+def test_transcript_overlapping_tasks_are_flat_not_nested():
+ t = TranscriptBuffer()
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_a",
+ "name": "Task",
+ "input": {"description": "A"},
+ }
+ )
+ t.apply(
+ {
+ "type": "tool_use",
+ "id": "task_b",
+ "name": "Task",
+ "input": {"description": "B"},
+ }
+ )
+ t.apply({"type": "tool_result", "tool_use_id": "task_b", "content": "done"})
+ t.apply({"type": "tool_result", "tool_use_id": "task_a", "content": "done"})
+
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert "🤖 *Subagent:* `A`" in out
+ assert "🤖 *Subagent:* `B`" in out
+ assert out.find("🤖 *Subagent:* `A`") < out.find("🤖 *Subagent:* `B`")
+ assert "\n 🤖 *Subagent:* `B`" not in out
+
+
+def test_transcript_truncates_by_dropping_oldest_segments():
+ t = TranscriptBuffer()
+
+ # Create many segments by opening/closing distinct text blocks.
+ for i in range(60):
+ t.apply({"type": "text_start", "index": i})
+ t.apply(
+ {"type": "text_delta", "index": i, "text": f"segment_{i} " + ("x" * 120)}
+ )
+ t.apply({"type": "block_stop", "index": i})
+
+ out = t.render(_ctx(), limit_chars=600, status="status")
+ assert escape_md_v2("... (truncated)") in out
+ # We keep the tail and drop the oldest segments when truncating.
+ assert escape_md_v2("segment_59") in out
+ assert escape_md_v2("segment_0") not in out
+
+
+def test_transcript_render_many_segments_completes_quickly():
+ """Render with 200+ segments exercises O(n) truncation (deque popleft)."""
+ t = TranscriptBuffer()
+ for i in range(200):
+ t.apply({"type": "text_start", "index": i})
+ t.apply({"type": "text_delta", "index": i, "text": f"seg_{i} " + ("y" * 80)})
+ t.apply({"type": "block_stop", "index": i})
+
+ out = t.render(_ctx(), limit_chars=500, status="ok")
+ assert escape_md_v2("... (truncated)") in out
+ assert "199" in out # last segment (MarkdownV2 escapes underscores)
+ assert "seg_0 " not in out # oldest segment dropped
+
+
+def test_transcript_reused_index_closes_previous_open_block():
+ t = TranscriptBuffer()
+ # Open a text block at index 0, but never close it.
+ t.apply({"type": "text_start", "index": 0})
+ t.apply({"type": "text_delta", "index": 0, "text": "a"})
+ # Provider reuses index 0 for a new tool block without a stop.
+ t.apply(
+ {"type": "tool_use_start", "index": 0, "id": "t1", "name": "ls", "input": {}}
+ )
+ # Old open text should have been closed.
+ assert 0 not in t._open_text_by_index
+ assert 0 in t._open_tools_by_index
+
+
+def test_transcript_render_segment_exception_skipped():
+ """When a segment's render() raises, that segment is skipped and rest is rendered."""
+ t = TranscriptBuffer()
+ t.apply({"type": "thinking_chunk", "text": "before"})
+ t.apply({"type": "text_chunk", "text": "middle"})
+ t.apply({"type": "text_chunk", "text": "after"})
+
+ bad_segment = t._segments[1]
+
+ def _raising_render(self, ctx):
+ raise ValueError("render failed")
+
+ with patch.object(bad_segment, "render", _raising_render):
+ out = t.render(_ctx(), limit_chars=3900, status=None)
+ assert "before" in out
+ assert "after" in out
+ assert "middle" not in out
+
+
+def test_transcript_render_status_only_exceeds_limit():
+ """When all segments dropped, status-only output; long status returned as-is."""
+ t = TranscriptBuffer()
+ t.apply({"type": "text_chunk", "text": "x" * 5000})
+
+ long_status = "A" * 500
+ msg = t.render(_ctx(), limit_chars=100, status=long_status)
+ assert "... (truncated)" in msg or long_status in msg
+
+
+def test_transcript_truncation_preserves_last_segment_tail():
+ """When all segments exceed limit, preserve tail of last segment (not just marker+status)."""
+ t = TranscriptBuffer()
+ t.apply({"type": "thinking_chunk", "text": "Thinking..."})
+ t.apply(
+ {"type": "text_chunk", "text": "The actual output content here" + "x" * 500}
+ )
+
+ msg = t.render(_ctx(), limit_chars=100, status="✅ *Complete*")
+ # Must include actual content (tail of last segment), not only "... (truncated)\n✅ *Complete*"
+ assert escape_md_v2("... (truncated)") in msg
+ assert "✅ *Complete*" in msg
+ assert "actual output" in msg or "content" in msg or "x" in msg
diff --git a/Claude_Code/tests/messaging/test_transcription.py b/Claude_Code/tests/messaging/test_transcription.py
new file mode 100644
index 0000000000000000000000000000000000000000..6aaaca49044bc9edfe0b5bcc04bfa03c676712cb
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_transcription.py
@@ -0,0 +1,125 @@
+"""Tests for voice note transcription."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from messaging.transcription import (
+ MAX_AUDIO_SIZE_BYTES,
+ transcribe_audio,
+)
+
+
+def test_transcribe_file_not_found_raises():
+ """Non-existent file raises FileNotFoundError."""
+ with pytest.raises(FileNotFoundError, match="not found"):
+ transcribe_audio(Path("/nonexistent/file.ogg"), "audio/ogg")
+
+
+def test_transcribe_file_too_large_raises():
+ """File exceeding max size raises ValueError."""
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"x" * (MAX_AUDIO_SIZE_BYTES + 1))
+ path = Path(f.name)
+ try:
+ with pytest.raises(ValueError, match="too large"):
+ transcribe_audio(path, "audio/ogg", whisper_device="auto")
+ finally:
+ path.unlink(missing_ok=True)
+
+
+def test_transcribe_local_success():
+ """Local backend returns transcribed text."""
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"fake ogg content")
+ path = Path(f.name)
+ try:
+ mock_pipe = MagicMock()
+ mock_pipe.return_value = {"text": "Hello world"}
+ fake_audio = {"array": [0.0], "sampling_rate": 16000}
+
+ with (
+ patch("messaging.transcription._load_audio", return_value=fake_audio),
+ patch(
+ "messaging.transcription._get_pipeline",
+ return_value=mock_pipe,
+ ),
+ ):
+ result = transcribe_audio(path, "audio/ogg", whisper_model="base")
+
+ assert result == "Hello world"
+ mock_pipe.assert_called_once_with(
+ fake_audio, generate_kwargs={"language": "en", "task": "transcribe"}
+ )
+ finally:
+ path.unlink(missing_ok=True)
+
+
+def test_transcribe_local_empty_segments_returns_no_speech():
+ """Local backend with no speech returns placeholder."""
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"fake ogg")
+ path = Path(f.name)
+ try:
+ mock_pipe = MagicMock()
+ mock_pipe.return_value = {"text": ""}
+ fake_audio = {"array": [0.0], "sampling_rate": 16000}
+
+ with (
+ patch("messaging.transcription._load_audio", return_value=fake_audio),
+ patch(
+ "messaging.transcription._get_pipeline",
+ return_value=mock_pipe,
+ ),
+ ):
+ result = transcribe_audio(path, "audio/ogg", whisper_model="base")
+
+ assert result == "(no speech detected)"
+ finally:
+ path.unlink(missing_ok=True)
+
+
+def test_transcribe_invalid_device_raises():
+ """Invalid whisper_device raises ValueError."""
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"fake ogg")
+ path = Path(f.name)
+ try:
+ # Mock settings to return invalid device "auto"
+ mock_settings = MagicMock()
+ mock_settings.whisper_device = "auto"
+ mock_settings.whisper_model = "base"
+
+ # Patch _load_audio to avoid ImportError from missing librosa
+ # Device validation happens in _get_pipeline before torch import
+ with (
+ patch("messaging.transcription.get_settings", return_value=mock_settings),
+ patch("messaging.transcription._load_audio"),
+ pytest.raises(ValueError, match="whisper_device must be 'cpu' or 'cuda'"),
+ ):
+ transcribe_audio(path, "audio/ogg", whisper_device="auto")
+ finally:
+ path.unlink(missing_ok=True)
+
+
+def test_transcribe_local_import_error_raises():
+ """Local backend when voice_local extra not installed raises ImportError."""
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"fake ogg")
+ path = Path(f.name)
+ try:
+ with (
+ patch(
+ "messaging.transcription._get_pipeline",
+ side_effect=ImportError(
+ "Local Whisper requires the voice_local extra. "
+ "Install with: uv sync --extra voice_local"
+ ),
+ ),
+ pytest.raises(ImportError, match="voice_local extra"),
+ ):
+ transcribe_audio(path, "audio/ogg", whisper_device="auto")
+ finally:
+ path.unlink(missing_ok=True)
diff --git a/Claude_Code/tests/messaging/test_tree_concurrency.py b/Claude_Code/tests/messaging/test_tree_concurrency.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b54a1563e1839cdf5df9f0098d2d04f943e076f
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_tree_concurrency.py
@@ -0,0 +1,604 @@
+"""Concurrency and race condition tests for tree data structures and queue manager."""
+
+import asyncio
+
+import pytest
+
+from messaging.models import IncomingMessage
+from messaging.trees.data import MessageNode, MessageState, MessageTree
+from messaging.trees.queue_manager import TreeQueueManager
+
+
+def _make_incoming(text: str = "hello", msg_id: str = "m1") -> IncomingMessage:
+ """Create a minimal IncomingMessage for testing."""
+ return IncomingMessage(
+ text=text,
+ chat_id="chat1",
+ user_id="user1",
+ message_id=msg_id,
+ platform="test",
+ )
+
+
+def _make_tree(root_id: str = "root") -> MessageTree:
+ """Create a tree with a single root node."""
+ root = MessageNode(
+ node_id=root_id,
+ incoming=_make_incoming(msg_id=root_id),
+ status_message_id=f"status_{root_id}",
+ state=MessageState.PENDING,
+ )
+ return MessageTree(root)
+
+
+class TestMessageTreeConcurrency:
+ """Concurrency tests for MessageTree operations."""
+
+ @pytest.mark.asyncio
+ async def test_concurrent_add_node_serialized(self):
+ """Concurrent add_node calls should all succeed via lock serialization."""
+ tree = _make_tree("root")
+ count = 10
+
+ async def add(i: int):
+ return await tree.add_node(
+ node_id=f"child_{i}",
+ incoming=_make_incoming(msg_id=f"child_{i}"),
+ status_message_id=f"status_{i}",
+ parent_id="root",
+ )
+
+ results = await asyncio.gather(*[add(i) for i in range(count)])
+
+ assert len(results) == count
+ # All nodes plus root
+ assert len(tree.all_nodes()) == count + 1
+ # Root should have all children
+ root = tree.get_root()
+ assert len(root.children_ids) == count
+
+ @pytest.mark.asyncio
+ async def test_concurrent_enqueue_dequeue_no_loss(self):
+ """Concurrent enqueue/dequeue should not lose items."""
+ tree = _make_tree("root")
+
+ # Add nodes first
+ for i in range(10):
+ await tree.add_node(
+ node_id=f"n{i}",
+ incoming=_make_incoming(msg_id=f"n{i}"),
+ status_message_id=f"s{i}",
+ parent_id="root",
+ )
+
+ # Enqueue all concurrently
+ await asyncio.gather(*[tree.enqueue(f"n{i}") for i in range(10)])
+ assert tree.get_queue_size() == 10
+
+ # Dequeue all
+ dequeued = []
+ for _ in range(10):
+ nid = await tree.dequeue()
+ if nid:
+ dequeued.append(nid)
+
+ assert len(dequeued) == 10
+ assert set(dequeued) == {f"n{i}" for i in range(10)}
+ assert tree.get_queue_size() == 0
+
+ @pytest.mark.asyncio
+ async def test_dequeue_empty_returns_none(self):
+ """Dequeue on empty queue returns None."""
+ tree = _make_tree("root")
+ result = await tree.dequeue()
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_concurrent_update_state(self):
+ """Concurrent state updates should all apply (last writer wins)."""
+ tree = _make_tree("root")
+ for i in range(5):
+ await tree.add_node(
+ node_id=f"n{i}",
+ incoming=_make_incoming(msg_id=f"n{i}"),
+ status_message_id=f"s{i}",
+ parent_id="root",
+ )
+
+ # Update all nodes concurrently
+ await asyncio.gather(
+ *[tree.update_state(f"n{i}", MessageState.IN_PROGRESS) for i in range(5)]
+ )
+
+ for i in range(5):
+ node = tree.get_node(f"n{i}")
+ assert node is not None
+ assert node.state == MessageState.IN_PROGRESS
+
+ @pytest.mark.asyncio
+ async def test_update_state_nonexistent_node(self):
+ """Updating state of a nonexistent node should not raise."""
+ tree = _make_tree("root")
+ # Should just log a warning, not raise
+ await tree.update_state("nonexistent", MessageState.ERROR)
+
+ @pytest.mark.asyncio
+ async def test_add_node_invalid_parent_raises(self):
+ """Adding a node with nonexistent parent should raise ValueError."""
+ tree = _make_tree("root")
+ with pytest.raises(ValueError, match="not found in tree"):
+ await tree.add_node(
+ node_id="child",
+ incoming=_make_incoming(),
+ status_message_id="s1",
+ parent_id="nonexistent",
+ )
+
+ @pytest.mark.asyncio
+ async def test_queue_snapshot_matches_enqueue_order(self):
+ """Queue snapshot should return items in FIFO order."""
+ tree = _make_tree("root")
+ for i in range(5):
+ await tree.add_node(
+ node_id=f"n{i}",
+ incoming=_make_incoming(msg_id=f"n{i}"),
+ status_message_id=f"s{i}",
+ parent_id="root",
+ )
+
+ for i in range(5):
+ await tree.enqueue(f"n{i}")
+
+ snapshot = await tree.get_queue_snapshot()
+ assert snapshot == [f"n{i}" for i in range(5)]
+
+ @pytest.mark.asyncio
+ async def test_enqueue_returns_position(self):
+ """Enqueue should return 1-indexed position."""
+ tree = _make_tree("root")
+ for i in range(3):
+ await tree.add_node(
+ node_id=f"n{i}",
+ incoming=_make_incoming(msg_id=f"n{i}"),
+ status_message_id=f"s{i}",
+ parent_id="root",
+ )
+
+ pos1 = await tree.enqueue("n0")
+ pos2 = await tree.enqueue("n1")
+ pos3 = await tree.enqueue("n2")
+
+ assert pos1 == 1
+ assert pos2 == 2
+ assert pos3 == 3
+
+
+class TestMessageTreeNavigation:
+ """Tests for tree navigation methods."""
+
+ @pytest.mark.asyncio
+ async def test_get_children(self):
+ """get_children returns child nodes."""
+ tree = _make_tree("root")
+ await tree.add_node("c1", _make_incoming(msg_id="c1"), "s1", "root")
+ await tree.add_node("c2", _make_incoming(msg_id="c2"), "s2", "root")
+
+ children = tree.get_children("root")
+ assert len(children) == 2
+ assert {c.node_id for c in children} == {"c1", "c2"}
+
+ def test_get_children_nonexistent(self):
+ """get_children for nonexistent node returns empty list."""
+ tree = _make_tree("root")
+ assert tree.get_children("nonexistent") == []
+
+ def test_get_parent_root(self):
+ """Root node has no parent."""
+ tree = _make_tree("root")
+ assert tree.get_parent("root") is None
+
+ @pytest.mark.asyncio
+ async def test_get_parent_child(self):
+ """Child node's parent is the root."""
+ tree = _make_tree("root")
+ await tree.add_node("c1", _make_incoming(msg_id="c1"), "s1", "root")
+ parent = tree.get_parent("c1")
+ assert parent is not None
+ assert parent.node_id == "root"
+
+ @pytest.mark.asyncio
+ async def test_get_parent_session_id(self):
+ """get_parent_session_id returns parent's session_id."""
+ tree = _make_tree("root")
+ await tree.update_state("root", MessageState.COMPLETED, session_id="sess_abc")
+ await tree.add_node("c1", _make_incoming(msg_id="c1"), "s1", "root")
+
+ session_id = tree.get_parent_session_id("c1")
+ assert session_id == "sess_abc"
+
+ def test_get_parent_session_id_root(self):
+ """Root node has no parent session."""
+ tree = _make_tree("root")
+ assert tree.get_parent_session_id("root") is None
+
+ def test_has_node(self):
+ """has_node returns True for existing nodes."""
+ tree = _make_tree("root")
+ assert tree.has_node("root") is True
+ assert tree.has_node("nonexistent") is False
+
+ @pytest.mark.asyncio
+ async def test_find_node_by_status_message(self):
+ """find_node_by_status_message finds the right node."""
+ tree = _make_tree("root")
+ await tree.add_node("c1", _make_incoming(msg_id="c1"), "status_c1", "root")
+
+ found = tree.find_node_by_status_message("status_c1")
+ assert found is not None
+ assert found.node_id == "c1"
+
+ def test_find_node_by_status_message_not_found(self):
+ """find_node_by_status_message returns None if not found."""
+ tree = _make_tree("root")
+ assert tree.find_node_by_status_message("nonexistent") is None
+
+
+class TestMessageTreeSerialization:
+ """Tests for tree serialization/deserialization."""
+
+ @pytest.mark.asyncio
+ async def test_round_trip(self):
+ """Tree should survive serialization round-trip."""
+ tree = _make_tree("root")
+ await tree.add_node("c1", _make_incoming(msg_id="c1"), "s1", "root")
+ await tree.add_node("c2", _make_incoming(msg_id="c2"), "s2", "root")
+ await tree.update_state("root", MessageState.COMPLETED, session_id="sess1")
+
+ data = tree.to_dict()
+ restored = MessageTree.from_dict(data)
+
+ assert restored.root_id == "root"
+ assert len(restored.all_nodes()) == 3
+ root = restored.get_root()
+ assert root.state == MessageState.COMPLETED
+ assert root.session_id == "sess1"
+ assert set(root.children_ids) == {"c1", "c2"}
+
+ @pytest.mark.asyncio
+ async def test_node_round_trip(self):
+ """MessageNode should survive serialization round-trip."""
+ node = MessageNode(
+ node_id="n1",
+ incoming=_make_incoming(msg_id="n1"),
+ status_message_id="s1",
+ state=MessageState.COMPLETED,
+ parent_id="root",
+ session_id="sess_test",
+ error_message="test error",
+ )
+ data = node.to_dict()
+ restored = MessageNode.from_dict(data)
+
+ assert restored.node_id == "n1"
+ assert restored.state == MessageState.COMPLETED
+ assert restored.session_id == "sess_test"
+ assert restored.error_message == "test error"
+ assert restored.parent_id == "root"
+
+
+class TestTreeQueueManagerConcurrency:
+ """Concurrency tests for TreeQueueManager."""
+
+ @pytest.mark.asyncio
+ async def test_concurrent_create_trees(self):
+ """Creating multiple trees concurrently should all succeed."""
+ mgr = TreeQueueManager()
+
+ async def create(i: int):
+ return await mgr.create_tree(
+ node_id=f"root_{i}",
+ incoming=_make_incoming(msg_id=f"root_{i}"),
+ status_message_id=f"status_{i}",
+ )
+
+ trees = await asyncio.gather(*[create(i) for i in range(10)])
+ assert len(trees) == 10
+ assert mgr.get_tree_count() == 10
+
+ @pytest.mark.asyncio
+ async def test_add_to_tree_concurrent(self):
+ """Adding replies to a tree concurrently should all succeed."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+
+ async def add_reply(i: int):
+ return await mgr.add_to_tree(
+ parent_node_id="root",
+ node_id=f"reply_{i}",
+ incoming=_make_incoming(msg_id=f"reply_{i}"),
+ status_message_id=f"s_reply_{i}",
+ )
+
+ results = await asyncio.gather(*[add_reply(i) for i in range(5)])
+ assert len(results) == 5
+ tree = mgr.get_tree("root")
+ assert tree is not None
+ assert len(tree.all_nodes()) == 6 # root + 5 replies
+
+ @pytest.mark.asyncio
+ async def test_add_to_tree_invalid_parent(self):
+ """Adding to a nonexistent parent should raise ValueError."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+
+ with pytest.raises(ValueError, match="not found"):
+ await mgr.add_to_tree(
+ parent_node_id="nonexistent",
+ node_id="reply",
+ incoming=_make_incoming(),
+ status_message_id="s1",
+ )
+
+ @pytest.mark.asyncio
+ async def test_enqueue_and_process(self):
+ """Enqueue should process immediately if tree is free."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+
+ processed = []
+
+ async def processor(node_id, node):
+ processed.append(node_id)
+
+ queued = await mgr.enqueue("root", processor)
+ # Should process immediately (not queued)
+ assert queued is False
+
+ # Wait for the async task to complete
+ await asyncio.sleep(0.1)
+ assert "root" in processed
+
+ @pytest.mark.asyncio
+ async def test_enqueue_queues_when_busy(self):
+ """Enqueue should queue when tree is already processing."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+
+ processing_started = asyncio.Event()
+ release = asyncio.Event()
+
+ async def slow_processor(node_id, node):
+ processing_started.set()
+ await release.wait()
+
+ # Start processing root (will block)
+ queued_first = await mgr.enqueue("root", slow_processor)
+ assert queued_first is False
+ await processing_started.wait()
+
+ # Now tree is busy, second enqueue should be queued
+ queued_second = await mgr.enqueue("c1", slow_processor)
+ assert queued_second is True
+
+ # Release the blocker so things clean up
+ release.set()
+ await asyncio.sleep(0.2)
+
+ @pytest.mark.asyncio
+ async def test_cancel_tree(self):
+ """cancel_tree should cancel in-progress and queued nodes."""
+ mgr = TreeQueueManager()
+ tree = await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+ _, _ = await mgr.add_to_tree("root", "c2", _make_incoming(msg_id="c2"), "s2")
+
+ processing_started = asyncio.Event()
+
+ async def slow_processor(node_id, node):
+ processing_started.set()
+ await asyncio.sleep(10) # Long running
+
+ # Start processing root
+ await mgr.enqueue("root", slow_processor)
+ await processing_started.wait()
+
+ # Queue additional nodes
+ await mgr.enqueue("c1", slow_processor)
+ await mgr.enqueue("c2", slow_processor)
+
+ # Cancel the tree
+ cancelled = await mgr.cancel_tree("root")
+ assert len(cancelled) >= 1 # At least the current + queued
+
+ # Tree should no longer be processing
+ assert tree._is_processing is False
+
+ @pytest.mark.asyncio
+ async def test_cancel_nonexistent_tree(self):
+ """cancel_tree for nonexistent tree returns empty list."""
+ mgr = TreeQueueManager()
+ result = await mgr.cancel_tree("nonexistent")
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_cancel_all(self):
+ """cancel_all cancels all trees."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("t1", _make_incoming(msg_id="t1"), "s1")
+ await mgr.create_tree("t2", _make_incoming(msg_id="t2"), "s2")
+
+ # Mark nodes as PENDING (they already are by default)
+ cancelled = await mgr.cancel_all()
+ # Nodes were PENDING but not in queue, so cleanup_stale logic applies
+ # At minimum, it should not raise
+ assert isinstance(cancelled, list)
+
+ @pytest.mark.asyncio
+ async def test_cleanup_stale_nodes(self):
+ """cleanup_stale_nodes marks PENDING/IN_PROGRESS nodes as ERROR."""
+ mgr = TreeQueueManager()
+ tree = await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+
+ # Root is PENDING, c1 is PENDING
+ count = mgr.cleanup_stale_nodes()
+ assert count == 2
+
+ root = tree.get_node("root")
+ assert root is not None
+ assert root.state == MessageState.ERROR
+ assert root.error_message is not None
+ assert "restart" in root.error_message
+
+ @pytest.mark.asyncio
+ async def test_mark_node_error_with_propagation(self):
+ """mark_node_error should propagate to pending children."""
+ mgr = TreeQueueManager()
+ tree = await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+ _, _ = await mgr.add_to_tree("c1", "c2", _make_incoming(msg_id="c2"), "s2")
+
+ affected = await mgr.mark_node_error("root", "something failed")
+ # root + c1 + c2 should all be marked
+ assert len(affected) >= 1
+ root = tree.get_node("root")
+ assert root is not None
+ assert root.state == MessageState.ERROR
+
+ @pytest.mark.asyncio
+ async def test_mark_node_error_nonexistent(self):
+ """mark_node_error for nonexistent node returns empty."""
+ mgr = TreeQueueManager()
+ result = await mgr.mark_node_error("nonexistent", "err")
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_get_tree_for_node(self):
+ """get_tree_for_node returns the right tree."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+
+ tree = mgr.get_tree_for_node("c1")
+ assert tree is not None
+ assert tree.root_id == "root"
+
+ def test_get_tree_for_node_nonexistent(self):
+ """get_tree_for_node returns None for unknown nodes."""
+ mgr = TreeQueueManager()
+ assert mgr.get_tree_for_node("nonexistent") is None
+
+ @pytest.mark.asyncio
+ async def test_enqueue_no_tree(self):
+ """Enqueue for a node not in any tree returns False."""
+ mgr = TreeQueueManager()
+
+ async def dummy(nid, node):
+ pass
+
+ result = await mgr.enqueue("nonexistent", dummy)
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_serialization_round_trip(self):
+ """TreeQueueManager should survive serialization round-trip."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+
+ data = mgr.to_dict()
+ restored = TreeQueueManager.from_dict(data)
+
+ assert restored.get_tree_count() == 1
+ assert restored.get_node("c1") is not None
+
+ @pytest.mark.asyncio
+ async def test_rapid_messages_all_queued(self):
+ """Rapid sequential enqueues should all be queued without loss."""
+ mgr = TreeQueueManager()
+ tree = await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+
+ # Add 10 child nodes
+ for i in range(10):
+ await mgr.add_to_tree(
+ "root", f"c{i}", _make_incoming(msg_id=f"c{i}"), f"s{i}"
+ )
+
+ blocker = asyncio.Event()
+
+ async def blocking_processor(nid, node):
+ await blocker.wait()
+
+ # Start processing root (blocks)
+ await mgr.enqueue("root", blocking_processor)
+ await asyncio.sleep(0.05) # Let task start
+
+ # Rapidly enqueue all children
+ results = []
+ for i in range(10):
+ r = await mgr.enqueue(f"c{i}", blocking_processor)
+ results.append(r)
+
+ # All should be queued (True)
+ assert all(r is True for r in results)
+ assert tree.get_queue_size() == 10
+
+ # Cleanup
+ blocker.set()
+ await asyncio.sleep(0.1)
+
+ @pytest.mark.asyncio
+ async def test_concurrent_trees_independent(self):
+ """Processing in one tree shouldn't affect another."""
+ mgr = TreeQueueManager()
+ await mgr.create_tree("t1", _make_incoming(msg_id="t1"), "s1")
+ await mgr.create_tree("t2", _make_incoming(msg_id="t2"), "s2")
+
+ processed = []
+
+ async def processor(nid, node):
+ processed.append(nid)
+
+ # Process both trees
+ await mgr.enqueue("t1", processor)
+ await mgr.enqueue("t2", processor)
+ await asyncio.sleep(0.2)
+
+ assert "t1" in processed
+ assert "t2" in processed
+
+ @pytest.mark.asyncio
+ async def test_callbacks_invoked(self):
+ """Queue update and node started callbacks should fire."""
+ queue_updates = []
+ node_starts = []
+
+ async def on_queue_update(tree):
+ queue_updates.append(tree.root_id)
+
+ async def on_node_started(tree, node_id):
+ node_starts.append(node_id)
+
+ mgr = TreeQueueManager(
+ queue_update_callback=on_queue_update,
+ node_started_callback=on_node_started,
+ )
+ await mgr.create_tree("root", _make_incoming(msg_id="root"), "s_root")
+ _, _ = await mgr.add_to_tree("root", "c1", _make_incoming(msg_id="c1"), "s1")
+
+ blocker = asyncio.Event()
+
+ async def slow_proc(nid, node):
+ if nid == "root":
+ blocker.set()
+ await asyncio.sleep(0.1)
+
+ # Process root then c1 should be dequeued
+ await mgr.enqueue("root", slow_proc)
+ await blocker.wait()
+ await mgr.enqueue("c1", slow_proc)
+ await asyncio.sleep(0.5)
+
+ # c1 was dequeued from queue, so callbacks should have fired
+ assert len(queue_updates) >= 1 or len(node_starts) >= 1
diff --git a/Claude_Code/tests/messaging/test_tree_processor.py b/Claude_Code/tests/messaging/test_tree_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6a3a037107200553e3d95adbb55bbcefb73991e
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_tree_processor.py
@@ -0,0 +1,184 @@
+import asyncio
+import contextlib
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from messaging.models import IncomingMessage
+from messaging.trees.data import MessageNode, MessageState, MessageTree
+from messaging.trees.processor import TreeQueueProcessor
+
+
+@pytest.fixture
+def tree_processor():
+ return TreeQueueProcessor()
+
+
+@pytest.fixture
+def sample_incoming():
+ return IncomingMessage(
+ text="test message",
+ chat_id="chat123",
+ user_id="user456",
+ message_id="msg789",
+ platform="telegram",
+ )
+
+
+@pytest.fixture
+def sample_node(sample_incoming):
+ return MessageNode(
+ node_id="msg789", incoming=sample_incoming, status_message_id="status123"
+ )
+
+
+@pytest.fixture
+def sample_tree(sample_node):
+ return MessageTree(sample_node)
+
+
+@pytest.mark.asyncio
+async def test_process_node_success(tree_processor, sample_tree, sample_node):
+ processor = AsyncMock()
+
+ await tree_processor.process_node(sample_tree, sample_node, processor)
+
+ processor.assert_called_once_with(sample_node.node_id, sample_node)
+ assert sample_tree._current_node_id is None
+
+
+@pytest.mark.asyncio
+async def test_process_node_cancelled(tree_processor, sample_tree, sample_node):
+ processor = AsyncMock(side_effect=asyncio.CancelledError)
+
+ with pytest.raises(asyncio.CancelledError):
+ await tree_processor.process_node(sample_tree, sample_node, processor)
+
+ assert sample_tree._current_node_id is None
+
+
+@pytest.mark.asyncio
+async def test_process_node_exception(tree_processor, sample_tree, sample_node):
+ processor = AsyncMock(side_effect=Exception("Test error"))
+
+ # We need to mock update_state to verify it was called
+ sample_tree.update_state = AsyncMock()
+
+ await tree_processor.process_node(sample_tree, sample_node, processor)
+
+ sample_tree.update_state.assert_called_once_with(
+ sample_node.node_id, MessageState.ERROR, error_message="Test error"
+ )
+ assert sample_tree._current_node_id is None
+
+
+@pytest.mark.asyncio
+async def test_enqueue_and_start_when_free(tree_processor, sample_tree):
+ processor = AsyncMock()
+ node_id = "node1"
+
+ # Mock get_node to return a node
+ node = MagicMock(spec=MessageNode)
+ sample_tree.get_node = MagicMock(return_value=node)
+
+ was_queued = await tree_processor.enqueue_and_start(sample_tree, node_id, processor)
+
+ assert was_queued is False
+ assert sample_tree._is_processing is True
+ assert sample_tree._current_node_id == node_id
+ assert sample_tree._current_task is not None
+
+ # Clean up task
+ sample_tree._current_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await sample_tree._current_task
+
+
+@pytest.mark.asyncio
+async def test_enqueue_and_start_when_busy(tree_processor, sample_tree):
+ processor = AsyncMock()
+ sample_tree._is_processing = True
+ node_id = "node1"
+
+ was_queued = await tree_processor.enqueue_and_start(sample_tree, node_id, processor)
+
+ assert was_queued is True
+ assert sample_tree._queue.qsize() == 1
+ assert sample_tree._queue.get_nowait() == node_id
+
+
+def test_cancel_current_task(tree_processor, sample_tree):
+ mock_task = MagicMock(spec=asyncio.Task)
+ mock_task.done.return_value = False
+ sample_tree._current_task = mock_task
+
+ cancelled = tree_processor.cancel_current(sample_tree)
+
+ assert cancelled is True
+ mock_task.cancel.assert_called_once()
+
+
+def test_cancel_current_task_already_done(tree_processor, sample_tree):
+ mock_task = MagicMock(spec=asyncio.Task)
+ mock_task.done.return_value = True
+ sample_tree._current_task = mock_task
+
+ cancelled = tree_processor.cancel_current(sample_tree)
+
+ assert cancelled is False
+ mock_task.cancel.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_process_next_queue_empty(tree_processor, sample_tree):
+ processor = AsyncMock()
+ sample_tree._is_processing = True
+
+ await tree_processor._process_next(sample_tree, processor)
+
+ assert sample_tree._is_processing is False
+
+
+@pytest.mark.asyncio
+async def test_process_next_with_item(tree_processor, sample_tree):
+ processor = AsyncMock()
+ await sample_tree._queue.put("next_node")
+
+ node = MagicMock(spec=MessageNode)
+ sample_tree.get_node = MagicMock(return_value=node)
+
+ await tree_processor._process_next(sample_tree, processor)
+
+ assert sample_tree._current_node_id == "next_node"
+ assert sample_tree._current_task is not None
+
+ # Clean up
+ sample_tree._current_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await sample_tree._current_task
+
+
+@pytest.mark.asyncio
+async def test_process_next_triggers_queue_update(sample_tree):
+ callback = AsyncMock()
+ processor = TreeQueueProcessor(queue_update_callback=callback)
+
+ await sample_tree._queue.put("next_node")
+ sample_tree.get_node = MagicMock(return_value=None)
+
+ await processor._process_next(sample_tree, AsyncMock())
+
+ callback.assert_awaited_once_with(sample_tree)
+
+
+@pytest.mark.asyncio
+async def test_process_next_triggers_node_started(sample_tree):
+ node_started = AsyncMock()
+ processor = TreeQueueProcessor(node_started_callback=node_started)
+
+ await sample_tree._queue.put("next_node")
+ sample_tree.get_node = MagicMock(return_value=None)
+
+ await processor._process_next(sample_tree, AsyncMock())
+
+ node_started.assert_awaited_once_with(sample_tree, "next_node")
diff --git a/Claude_Code/tests/messaging/test_tree_queue.py b/Claude_Code/tests/messaging/test_tree_queue.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed0c3d6b77d7d1a92282b4415e957b4f2be2a76c
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_tree_queue.py
@@ -0,0 +1,669 @@
+"""Tests for tree-based message queue system."""
+
+import asyncio
+
+import pytest
+
+from messaging.models import IncomingMessage
+from messaging.trees.queue_manager import (
+ MessageNode,
+ MessageState,
+ MessageTree,
+ TreeQueueManager,
+)
+
+
+class TestMessageState:
+ """Test MessageState enum."""
+
+ def test_state_values(self):
+ """Test state enum values."""
+ assert MessageState.PENDING.value == "pending"
+ assert MessageState.IN_PROGRESS.value == "in_progress"
+ assert MessageState.COMPLETED.value == "completed"
+ assert MessageState.ERROR.value == "error"
+
+
+class TestMessageNode:
+ """Test MessageNode dataclass."""
+
+ def test_node_creation(self):
+ """Test creating a message node."""
+ incoming = IncomingMessage(
+ text="Hello",
+ chat_id="123",
+ user_id="456",
+ message_id="789",
+ platform="telegram",
+ )
+ node = MessageNode(
+ node_id="789",
+ incoming=incoming,
+ status_message_id="status_1",
+ )
+
+ assert node.node_id == "789"
+ assert node.state == MessageState.PENDING
+ assert node.parent_id is None
+ assert node.children_ids == []
+ assert node.session_id is None
+
+ def test_node_to_dict(self):
+ """Test serializing a node."""
+ incoming = IncomingMessage(
+ text="Test",
+ chat_id="1",
+ user_id="2",
+ message_id="3",
+ platform="test",
+ )
+ node = MessageNode(
+ node_id="3",
+ incoming=incoming,
+ status_message_id="s1",
+ state=MessageState.COMPLETED,
+ session_id="sess_123",
+ )
+
+ data = node.to_dict()
+ assert data["node_id"] == "3"
+ assert data["state"] == "completed"
+ assert data["session_id"] == "sess_123"
+
+ def test_node_from_dict(self):
+ """Test deserializing a node."""
+ data = {
+ "node_id": "n1",
+ "incoming": {
+ "text": "Hello",
+ "chat_id": "c1",
+ "user_id": "u1",
+ "message_id": "m1",
+ "platform": "test",
+ },
+ "status_message_id": "s1",
+ "state": "in_progress",
+ "parent_id": "parent_1",
+ "session_id": None,
+ "children_ids": ["child_1"],
+ "created_at": "2025-01-01T00:00:00",
+ }
+
+ node = MessageNode.from_dict(data)
+ assert node.node_id == "n1"
+ assert node.state == MessageState.IN_PROGRESS
+ assert node.parent_id == "parent_1"
+ assert "child_1" in node.children_ids
+
+
+class TestMessageTree:
+ """Test MessageTree class."""
+
+ def test_tree_creation(self):
+ """Test creating a tree with root node."""
+ incoming = IncomingMessage(
+ text="Root",
+ chat_id="1",
+ user_id="1",
+ message_id="root_msg",
+ platform="test",
+ )
+ root = MessageNode(
+ node_id="root_msg",
+ incoming=incoming,
+ status_message_id="status_1",
+ )
+
+ tree = MessageTree(root)
+ assert tree.root_id == "root_msg"
+ assert tree.get_node("root_msg") is not None
+ assert tree.is_processing is False
+
+ @pytest.mark.asyncio
+ async def test_add_child_node(self):
+ """Test adding a child node to the tree."""
+ # Create root
+ root_incoming = IncomingMessage(
+ text="Root",
+ chat_id="1",
+ user_id="1",
+ message_id="root",
+ platform="test",
+ )
+ root = MessageNode(
+ node_id="root",
+ incoming=root_incoming,
+ status_message_id="s1",
+ )
+ tree = MessageTree(root)
+
+ # Add child
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="1",
+ user_id="1",
+ message_id="child",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ child = await tree.add_node(
+ node_id="child",
+ incoming=child_incoming,
+ status_message_id="s2",
+ parent_id="root",
+ )
+
+ assert child.node_id == "child"
+ assert child.parent_id == "root"
+ assert "child" in tree.get_root().children_ids
+ parent = tree.get_parent("child")
+ assert parent is not None
+ assert parent.node_id == "root"
+
+ @pytest.mark.asyncio
+ async def test_update_state(self):
+ """Test updating node state."""
+ incoming = IncomingMessage(
+ text="Test",
+ chat_id="1",
+ user_id="1",
+ message_id="m1",
+ platform="test",
+ )
+ root = MessageNode(node_id="m1", incoming=incoming, status_message_id="s1")
+ tree = MessageTree(root)
+
+ await tree.update_state("m1", MessageState.IN_PROGRESS)
+ node = tree.get_node("m1")
+ assert node is not None
+ assert node.state == MessageState.IN_PROGRESS
+
+ await tree.update_state("m1", MessageState.COMPLETED, session_id="sess_abc")
+ node = tree.get_node("m1")
+ assert node is not None
+ assert node.state == MessageState.COMPLETED
+ assert node.session_id == "sess_abc"
+ assert node.completed_at is not None
+
+ @pytest.mark.asyncio
+ async def test_enqueue_dequeue(self):
+ """Test queue operations."""
+ incoming = IncomingMessage(
+ text="Test",
+ chat_id="1",
+ user_id="1",
+ message_id="m1",
+ platform="test",
+ )
+ root = MessageNode(node_id="m1", incoming=incoming, status_message_id="s1")
+ tree = MessageTree(root)
+
+ # Enqueue
+ pos = await tree.enqueue("m1")
+ assert pos == 1
+ assert tree.get_queue_size() == 1
+
+ # Dequeue
+ node_id = await tree.dequeue()
+ assert node_id == "m1"
+ assert tree.get_queue_size() == 0
+
+ @pytest.mark.asyncio
+ async def test_queue_snapshot(self):
+ """Test queue snapshot order."""
+ incoming = IncomingMessage(
+ text="Root",
+ chat_id="1",
+ user_id="1",
+ message_id="root",
+ platform="test",
+ )
+ root = MessageNode(node_id="root", incoming=incoming, status_message_id="s1")
+ tree = MessageTree(root)
+
+ child_incoming_1 = IncomingMessage(
+ text="Child 1",
+ chat_id="1",
+ user_id="1",
+ message_id="child_1",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ child_incoming_2 = IncomingMessage(
+ text="Child 2",
+ chat_id="1",
+ user_id="1",
+ message_id="child_2",
+ platform="test",
+ reply_to_message_id="root",
+ )
+
+ await tree.add_node(
+ node_id="child_1",
+ incoming=child_incoming_1,
+ status_message_id="s2",
+ parent_id="root",
+ )
+ await tree.add_node(
+ node_id="child_2",
+ incoming=child_incoming_2,
+ status_message_id="s3",
+ parent_id="root",
+ )
+
+ await tree.enqueue("child_1")
+ await tree.enqueue("child_2")
+
+ snapshot = await tree.get_queue_snapshot()
+ assert snapshot == ["child_1", "child_2"]
+
+ def test_tree_serialization(self):
+ """Test tree to_dict and from_dict."""
+ incoming = IncomingMessage(
+ text="Test",
+ chat_id="1",
+ user_id="1",
+ message_id="m1",
+ platform="test",
+ )
+ root = MessageNode(
+ node_id="m1",
+ incoming=incoming,
+ status_message_id="s1",
+ state=MessageState.COMPLETED,
+ session_id="sess_1",
+ )
+ tree = MessageTree(root)
+
+ data = tree.to_dict()
+ restored = MessageTree.from_dict(data)
+
+ assert restored.root_id == "m1"
+ node = restored.get_node("m1")
+ assert node is not None
+ assert node.session_id == "sess_1"
+
+ @pytest.mark.asyncio
+ async def test_get_descendants(self):
+ """Test get_descendants returns node and all descendants."""
+ root_incoming = IncomingMessage(
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
+ )
+ root = MessageNode(
+ node_id="root", incoming=root_incoming, status_message_id="s1"
+ )
+ tree = MessageTree(root)
+
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="1",
+ user_id="1",
+ message_id="child",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ await tree.add_node("child", child_incoming, "s2", "root")
+
+ grandchild_incoming = IncomingMessage(
+ text="Grand",
+ chat_id="1",
+ user_id="1",
+ message_id="grand",
+ platform="test",
+ reply_to_message_id="child",
+ )
+ await tree.add_node("grand", grandchild_incoming, "s3", "child")
+
+ assert tree.get_descendants("root") == ["root", "child", "grand"]
+ assert tree.get_descendants("child") == ["child", "grand"]
+ assert tree.get_descendants("grand") == ["grand"]
+ assert tree.get_descendants("nonexistent") == []
+
+ @pytest.mark.asyncio
+ async def test_remove_branch(self):
+ """Test remove_branch removes subtree and updates parent."""
+ root_incoming = IncomingMessage(
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
+ )
+ root = MessageNode(
+ node_id="root", incoming=root_incoming, status_message_id="s1"
+ )
+ tree = MessageTree(root)
+
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="1",
+ user_id="1",
+ message_id="child",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ await tree.add_node("child", child_incoming, "s2", "root")
+
+ grandchild_incoming = IncomingMessage(
+ text="Grand",
+ chat_id="1",
+ user_id="1",
+ message_id="grand",
+ platform="test",
+ reply_to_message_id="child",
+ )
+ await tree.add_node("grand", grandchild_incoming, "s3", "child")
+
+ async with tree.with_lock():
+ removed = tree.remove_branch("child")
+
+ assert len(removed) == 2
+ assert {n.node_id for n in removed} == {"child", "grand"}
+ assert tree.get_node("child") is None
+ assert tree.get_node("grand") is None
+ assert tree.get_node("root") is not None
+ assert "child" not in tree.get_root().children_ids
+
+
+class TestTreeQueueManager:
+ """Test TreeQueueManager class."""
+
+ @pytest.mark.asyncio
+ async def test_create_tree(self):
+ """Test creating a new tree."""
+ manager = TreeQueueManager()
+
+ incoming = IncomingMessage(
+ text="New message",
+ chat_id="1",
+ user_id="1",
+ message_id="msg_1",
+ platform="test",
+ )
+
+ tree = await manager.create_tree(
+ node_id="msg_1",
+ incoming=incoming,
+ status_message_id="status_1",
+ )
+
+ assert tree is not None
+ assert tree.root_id == "msg_1"
+ assert manager.get_tree("msg_1") is tree
+
+ @pytest.mark.asyncio
+ async def test_add_reply_to_tree(self):
+ """Test adding a reply to existing tree."""
+ manager = TreeQueueManager()
+
+ # Create root
+ root_incoming = IncomingMessage(
+ text="Root",
+ chat_id="1",
+ user_id="1",
+ message_id="root",
+ platform="test",
+ )
+ await manager.create_tree("root", root_incoming, "s1")
+
+ # Add reply
+ reply_incoming = IncomingMessage(
+ text="Reply",
+ chat_id="1",
+ user_id="1",
+ message_id="reply",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ tree, node = await manager.add_to_tree(
+ parent_node_id="root",
+ node_id="reply",
+ incoming=reply_incoming,
+ status_message_id="s2",
+ )
+
+ assert node.parent_id == "root"
+ assert manager.get_tree_for_node("reply") is tree
+
+ @pytest.mark.asyncio
+ async def test_enqueue_and_process(self):
+ """Test enqueueing and processing."""
+ manager = TreeQueueManager()
+ processed = []
+
+ async def processor(node_id, node):
+ processed.append(node_id)
+ await asyncio.sleep(0.01) # Simulate work
+
+ incoming = IncomingMessage(
+ text="Test",
+ chat_id="1",
+ user_id="1",
+ message_id="m1",
+ platform="test",
+ )
+ await manager.create_tree("m1", incoming, "s1")
+
+ was_queued = await manager.enqueue("m1", processor)
+ assert was_queued is False # First message processes immediately
+
+ # Wait for processing
+ await asyncio.sleep(0.1)
+ assert "m1" in processed
+
+ @pytest.mark.asyncio
+ async def test_queue_when_busy(self):
+ """Test that messages queue when tree is busy."""
+ manager = TreeQueueManager()
+ processing_started = asyncio.Event()
+ processing_complete = asyncio.Event()
+
+ async def slow_processor(node_id, node):
+ processing_started.set()
+ await processing_complete.wait()
+
+ # Create tree with root
+ root_incoming = IncomingMessage(
+ text="Root",
+ chat_id="1",
+ user_id="1",
+ message_id="root",
+ platform="test",
+ )
+ await manager.create_tree("root", root_incoming, "s1")
+
+ # Start processing root
+ was_queued = await manager.enqueue("root", slow_processor)
+ assert was_queued is False
+
+ # Wait for processing to start
+ await processing_started.wait()
+
+ # Add a child
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="1",
+ user_id="1",
+ message_id="child",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ await manager.add_to_tree("root", "child", child_incoming, "s2")
+
+ # Try to enqueue child - should be queued since tree is busy
+ was_queued = await manager.enqueue("child", slow_processor)
+ assert was_queued is True
+ assert manager.get_queue_size("child") == 1
+
+ # Cleanup
+ processing_complete.set()
+
+ @pytest.mark.asyncio
+ async def test_cancel_tree(self):
+ """Test cancelling a tree."""
+ manager = TreeQueueManager()
+ processing_complete = asyncio.Event()
+
+ async def slow_processor(node_id, node):
+ await processing_complete.wait()
+
+ incoming = IncomingMessage(
+ text="Test",
+ chat_id="1",
+ user_id="1",
+ message_id="m1",
+ platform="test",
+ )
+ await manager.create_tree("m1", incoming, "s1")
+ await manager.enqueue("m1", slow_processor)
+
+ # Cancel
+ cancelled = await manager.cancel_tree("m1")
+ assert len(cancelled) == 1
+
+ processing_complete.set()
+
+ @pytest.mark.asyncio
+ async def test_cancel_branch(self):
+ """Test cancel_branch cancels only nodes in subtree."""
+ manager = TreeQueueManager()
+
+ root_incoming = IncomingMessage(
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
+ )
+ await manager.create_tree("root", root_incoming, "s1")
+
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="1",
+ user_id="1",
+ message_id="child",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ tree, _ = await manager.add_to_tree("root", "child", child_incoming, "s2")
+
+ sibling_incoming = IncomingMessage(
+ text="Sibling",
+ chat_id="1",
+ user_id="1",
+ message_id="sibling",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ await manager.add_to_tree("root", "sibling", sibling_incoming, "s3")
+
+ cancelled = await manager.cancel_branch("child")
+ assert len(cancelled) == 1
+ assert cancelled[0].node_id == "child"
+
+ child_node = tree.get_node("child")
+ assert child_node is not None
+ assert child_node.state == MessageState.ERROR
+
+ sibling_node = tree.get_node("sibling")
+ assert sibling_node is not None
+ assert sibling_node.state == MessageState.PENDING
+
+ @pytest.mark.asyncio
+ async def test_remove_branch_non_root(self):
+ """Test remove_branch removes only the subtree when branch is not root."""
+ manager = TreeQueueManager()
+
+ root_incoming = IncomingMessage(
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
+ )
+ await manager.create_tree("root", root_incoming, "s1")
+
+ child_incoming = IncomingMessage(
+ text="Child",
+ chat_id="1",
+ user_id="1",
+ message_id="child",
+ platform="test",
+ reply_to_message_id="root",
+ )
+ tree, _ = await manager.add_to_tree("root", "child", child_incoming, "s2")
+
+ removed, root_id, removed_entire = await manager.remove_branch("child")
+
+ assert len(removed) == 1
+ assert removed[0].node_id == "child"
+ assert root_id == "root"
+ assert removed_entire is False
+ assert manager.get_tree_for_node("child") is None
+ assert manager.get_tree("root") is not None
+ assert tree.get_node("child") is None
+ assert "child" not in tree.get_root().children_ids
+
+ @pytest.mark.asyncio
+ async def test_remove_branch_root_removes_tree(self):
+ """Test remove_branch when branch is root removes entire tree."""
+ manager = TreeQueueManager()
+
+ root_incoming = IncomingMessage(
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
+ )
+ await manager.create_tree("root", root_incoming, "s1")
+
+ removed, root_id, removed_entire = await manager.remove_branch("root")
+
+ assert len(removed) == 1
+ assert root_id == "root"
+ assert removed_entire is True
+ assert manager.get_tree("root") is None
+ assert manager.get_tree_for_node("root") is None
+
+
+class TestSessionStoreTrees:
+ """Test SessionStore tree methods."""
+
+ def test_save_and_get_tree(self, tmp_path):
+ """Test saving and retrieving a tree."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+
+ tree_data = {
+ "root_id": "root_1",
+ "nodes": {
+ "root_1": {
+ "node_id": "root_1",
+ "state": "completed",
+ "session_id": "sess_abc",
+ }
+ },
+ }
+
+ store.save_tree("root_1", tree_data)
+
+ retrieved = store.get_tree("root_1")
+ assert retrieved is not None
+ assert retrieved["root_id"] == "root_1"
+
+ def test_get_tree_by_root_id(self, tmp_path):
+ """Test getting tree by root ID and node mapping."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+
+ tree_data = {
+ "root_id": "root",
+ "nodes": {
+ "root": {"node_id": "root"},
+ "child": {"node_id": "child"},
+ },
+ }
+
+ store.save_tree("root", tree_data)
+
+ retrieved = store.get_tree("root")
+ assert retrieved is not None
+ assert retrieved["root_id"] == "root"
+ assert store.get_node_mapping()["child"] == "root"
+
+ def test_register_node(self, tmp_path):
+ """Test registering a node to a tree."""
+ from messaging.session import SessionStore
+
+ store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
+
+ store.register_node("new_node", "root_tree")
+
+ assert store.get_node_mapping()["new_node"] == "root_tree"
diff --git a/Claude_Code/tests/messaging/test_tree_repository.py b/Claude_Code/tests/messaging/test_tree_repository.py
new file mode 100644
index 0000000000000000000000000000000000000000..2860ae6ec1fad24f13bb853449e20b3f2b7feb39
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_tree_repository.py
@@ -0,0 +1,143 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from messaging.models import IncomingMessage
+from messaging.trees.data import MessageNode, MessageState, MessageTree
+from messaging.trees.repository import TreeRepository
+
+
+@pytest.fixture
+def repository():
+ return TreeRepository()
+
+
+@pytest.fixture
+def sample_tree():
+ incoming = IncomingMessage(
+ text="root",
+ chat_id="c1",
+ user_id="u1",
+ message_id="root_id",
+ platform="telegram",
+ )
+ node = MessageNode(node_id="root_id", incoming=incoming, status_message_id="s1")
+ return MessageTree(node)
+
+
+def test_add_and_get_tree(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+
+ assert repository.get_tree("root_id") == sample_tree
+ assert repository.get_tree_for_node("root_id") == sample_tree
+ assert repository.has_node("root_id")
+
+
+def test_get_tree_nonexistent(repository):
+ assert repository.get_tree("none") is None
+ assert repository.get_tree_for_node("none") is None
+
+
+def test_register_node(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ repository.register_node("child_id", "root_id")
+
+ assert repository.get_tree_for_node("child_id") == sample_tree
+ assert repository.has_node("child_id")
+
+
+def test_get_node(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ node = repository.get_node("root_id")
+
+ assert node is not None
+ assert node.node_id == "root_id"
+ assert repository.get_node("none") is None
+
+
+def test_is_tree_busy(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ assert repository.is_tree_busy("root_id") is False
+
+ sample_tree._is_processing = True
+ assert repository.is_tree_busy("root_id") is True
+ assert repository.is_node_tree_busy("root_id") is True
+
+
+def test_get_queue_size(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ assert repository.get_queue_size("root_id") == 0
+
+ # We can't easily put items in asyncio.Queue without async,
+ # but we can mock it for this unit test if needed, or just skip if it's too complex.
+ # Actually, we can use a mock queue since this is a unit test of the repository wrapper.
+ sample_tree._queue = MagicMock()
+ sample_tree._queue.qsize.return_value = 5
+
+ assert repository.get_queue_size("root_id") == 5
+
+
+def test_resolve_parent_node_id(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ repository.register_node("s1", "root_id")
+
+ # 1. Direct node match
+ assert repository.resolve_parent_node_id("root_id") == "root_id"
+
+ # 2. Status message match
+ # find_node_by_status_message is used inside resolve_parent_node_id
+ # sample_tree has root_id node with status_message_id "s1"
+ assert repository.resolve_parent_node_id("s1") == "root_id"
+
+ # 3. No match
+ assert repository.resolve_parent_node_id("unknown") is None
+
+
+def test_get_pending_children(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+
+ # Create a child node
+ child_incoming = IncomingMessage(
+ text="child",
+ chat_id="c1",
+ user_id="u1",
+ message_id="child_id",
+ platform="telegram",
+ )
+ child_node = MessageNode(
+ node_id="child_id",
+ incoming=child_incoming,
+ status_message_id="s2",
+ parent_id="root_id",
+ state=MessageState.PENDING,
+ )
+
+ sample_tree._nodes["child_id"] = child_node
+ sample_tree.get_node("root_id").children_ids.append("child_id")
+ repository.register_node("child_id", "root_id")
+
+ pending = repository.get_pending_children("root_id")
+ assert len(pending) == 1
+ assert pending[0].node_id == "child_id"
+
+
+def test_to_from_dict(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ data = repository.to_dict()
+
+ assert "trees" in data
+ assert "root_id" in data["trees"]
+ assert "node_to_tree" in data
+ assert data["node_to_tree"]["root_id"] == "root_id"
+
+ new_repo = TreeRepository.from_dict(data)
+ tree = new_repo.get_tree("root_id")
+ assert tree is not None
+ assert tree.root_id == "root_id"
+ assert new_repo.get_tree_for_node("root_id") == tree
+
+
+def test_all_trees(repository, sample_tree):
+ repository.add_tree("root_id", sample_tree)
+ assert len(repository.all_trees()) == 1
+ assert repository.tree_ids() == ["root_id"]
diff --git a/Claude_Code/tests/messaging/test_voice_handlers.py b/Claude_Code/tests/messaging/test_voice_handlers.py
new file mode 100644
index 0000000000000000000000000000000000000000..b2537c888bff2081844518fd430c462201d7d7a3
--- /dev/null
+++ b/Claude_Code/tests/messaging/test_voice_handlers.py
@@ -0,0 +1,187 @@
+"""Tests for voice note handling in Telegram and Discord platforms."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from messaging.platforms.discord import DISCORD_AVAILABLE, DiscordPlatform
+from messaging.platforms.telegram import TelegramPlatform
+
+
+@pytest.fixture
+def telegram_platform():
+ with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
+ return TelegramPlatform(bot_token="test_token", allowed_user_id="12345")
+
+
+@pytest.mark.asyncio
+async def test_telegram_voice_disabled_sends_reply(telegram_platform):
+ """When voice_note_enabled is False, reply with disabled message."""
+ mock_update = MagicMock()
+ mock_update.message.voice = MagicMock(file_id="f1", mime_type="audio/ogg")
+ mock_update.effective_user.id = 12345
+ mock_update.effective_chat.id = 6789
+ mock_update.message.reply_text = AsyncMock()
+
+ with patch(
+ "config.settings.get_settings",
+ return_value=MagicMock(voice_note_enabled=False),
+ ):
+ await telegram_platform._on_telegram_voice(mock_update, MagicMock())
+
+ mock_update.message.reply_text.assert_called_once_with("Voice notes are disabled.")
+
+
+@pytest.mark.asyncio
+async def test_telegram_voice_unauthorized_ignored(telegram_platform):
+ """Voice from unauthorized user is ignored (no reply)."""
+ mock_update = MagicMock()
+ mock_update.message.voice = MagicMock(file_id="f1", mime_type="audio/ogg")
+ mock_update.effective_user.id = 99999 # Not 12345
+ mock_update.message.reply_text = AsyncMock()
+
+ with patch(
+ "config.settings.get_settings",
+ return_value=MagicMock(voice_note_enabled=True),
+ ):
+ await telegram_platform._on_telegram_voice(mock_update, MagicMock())
+
+ mock_update.message.reply_text.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_telegram_voice_success_invokes_handler(telegram_platform):
+ """Successful transcription invokes message handler with transcribed text."""
+ handler = AsyncMock()
+ telegram_platform.on_message(handler)
+
+ mock_update = MagicMock()
+ mock_voice = MagicMock(file_id="f1", mime_type="audio/ogg")
+ mock_update.message.voice = mock_voice
+ mock_update.message.message_id = 42
+ mock_update.message.reply_to_message = None
+ mock_update.effective_user.id = 12345
+ mock_update.effective_chat.id = 6789
+ mock_update.message.reply_text = AsyncMock()
+
+ mock_file = AsyncMock()
+ mock_context = MagicMock()
+ mock_context.bot.get_file = AsyncMock(return_value=mock_file)
+
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"fake")
+ tmp_path = Path(f.name)
+
+ try:
+
+ async def fake_download(custom_path=None):
+ if custom_path:
+ Path(custom_path).write_bytes(b"fake ogg")
+
+ mock_file.download_to_drive = fake_download
+
+ mock_settings = MagicMock(
+ voice_note_enabled=True,
+ whisper_model="base",
+ )
+
+ mock_queue_send = AsyncMock(return_value="999")
+ with (
+ patch(
+ "config.settings.get_settings",
+ return_value=mock_settings,
+ ),
+ patch(
+ "messaging.transcription.transcribe_audio",
+ return_value="Hello from voice",
+ ),
+ patch.object(
+ telegram_platform,
+ "queue_send_message",
+ mock_queue_send,
+ ),
+ ):
+ await telegram_platform._on_telegram_voice(mock_update, mock_context)
+
+ mock_queue_send.assert_called_once()
+ call_args, call_kw = mock_queue_send.call_args
+ assert "Transcribing voice note" in call_args[1]
+ assert call_kw["reply_to"] == "42"
+ assert call_kw["fire_and_forget"] is False
+
+ handler.assert_called_once()
+ incoming = handler.call_args[0][0]
+ assert incoming.text == "Hello from voice"
+ assert incoming.chat_id == "6789"
+ assert incoming.user_id == "12345"
+ assert incoming.platform == "telegram"
+ assert incoming.status_message_id == "999"
+ finally:
+ tmp_path.unlink(missing_ok=True)
+
+
+@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed")
+class TestDiscordGetAudioAttachment:
+ """Tests for _get_audio_attachment helper."""
+
+ def test_returns_none_when_no_attachments(self):
+ platform = DiscordPlatform(bot_token="token")
+ msg = MagicMock()
+ msg.attachments = []
+ assert platform._get_audio_attachment(msg) is None
+
+ def test_returns_none_when_no_audio_attachments(self):
+ platform = DiscordPlatform(bot_token="token")
+ msg = MagicMock()
+ att = MagicMock()
+ att.content_type = "image/png"
+ att.filename = "pic.png"
+ msg.attachments = [att]
+ assert platform._get_audio_attachment(msg) is None
+
+ def test_returns_attachment_by_content_type(self):
+ platform = DiscordPlatform(bot_token="token")
+ msg = MagicMock()
+ att = MagicMock()
+ att.content_type = "audio/ogg"
+ att.filename = "voice.ogg"
+ msg.attachments = [att]
+ assert platform._get_audio_attachment(msg) is att
+
+ def test_returns_attachment_by_extension(self):
+ platform = DiscordPlatform(bot_token="token")
+ msg = MagicMock()
+ att = MagicMock()
+ att.content_type = "application/octet-stream"
+ att.filename = "voice.ogg"
+ msg.attachments = [att]
+ assert platform._get_audio_attachment(msg) is att
+
+
+@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed")
+@pytest.mark.asyncio
+async def test_discord_voice_disabled_sends_reply():
+ """When voice_note_enabled is False, reply with disabled message."""
+ platform = DiscordPlatform(bot_token="token", allowed_channel_ids="123")
+ platform._message_handler = None
+
+ mock_message = MagicMock()
+ mock_message.author.bot = False
+ mock_message.content = None
+ mock_message.channel.id = 123
+ mock_message.reply = AsyncMock()
+
+ mock_att = MagicMock()
+ mock_att.content_type = "audio/ogg"
+ mock_att.filename = "voice.ogg"
+ mock_message.attachments = [mock_att]
+
+ with patch(
+ "config.settings.get_settings",
+ return_value=MagicMock(voice_note_enabled=False),
+ ):
+ await platform._on_discord_message(mock_message)
+
+ mock_message.reply.assert_called_once_with("Voice notes are disabled.")
diff --git a/Claude_Code/tests/providers/test_converter.py b/Claude_Code/tests/providers/test_converter.py
new file mode 100644
index 0000000000000000000000000000000000000000..9fa3385241e1e888d567461a46f417b332e47447
--- /dev/null
+++ b/Claude_Code/tests/providers/test_converter.py
@@ -0,0 +1,414 @@
+import json
+
+import pytest
+
+from providers.common.message_converter import AnthropicToOpenAIConverter
+
+# --- Mock Classes ---
+
+
+class MockMessage:
+ def __init__(self, role, content):
+ self.role = role
+ self.content = content
+
+
+class MockBlock:
+ def __init__(self, **kwargs):
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+ self._data = kwargs
+
+ def get(self, key, default=None):
+ return self._data.get(key, default)
+
+
+class MockTool:
+ def __init__(self, name, description, input_schema):
+ self.name = name
+ self.description = description
+ self.input_schema = input_schema
+
+
+# --- System Prompt Tests ---
+
+
+def test_convert_system_prompt_str():
+ system = "You are a helpful assistant."
+ result = AnthropicToOpenAIConverter.convert_system_prompt(system)
+ assert result == {"role": "system", "content": system}
+
+
+def test_convert_system_prompt_list_text():
+ system = [
+ MockBlock(type="text", text="Part 1"),
+ MockBlock(type="text", text="Part 2"),
+ ]
+ result = AnthropicToOpenAIConverter.convert_system_prompt(system)
+ assert result == {"role": "system", "content": "Part 1\n\nPart 2"}
+
+
+def test_convert_system_prompt_none():
+ assert AnthropicToOpenAIConverter.convert_system_prompt(None) is None
+
+
+# --- Tool Conversion Tests ---
+
+
+def test_convert_tools():
+ tools = [
+ MockTool(
+ "get_weather",
+ "Get weather",
+ {"type": "object", "properties": {"loc": {"type": "string"}}},
+ ),
+ MockTool("calculator", None, {"type": "object"}),
+ ]
+ result = AnthropicToOpenAIConverter.convert_tools(tools)
+ assert len(result) == 2
+
+ assert result[0]["type"] == "function"
+ assert result[0]["function"]["name"] == "get_weather"
+ assert result[0]["function"]["description"] == "Get weather"
+ assert result[0]["function"]["parameters"] == {
+ "type": "object",
+ "properties": {"loc": {"type": "string"}},
+ }
+
+ assert result[1]["function"]["name"] == "calculator"
+ assert result[1]["function"]["description"] == "" # Check default empty string
+
+
+# --- Message Conversion Tests: User ---
+
+
+def test_convert_user_message_str():
+ messages = [MockMessage("user", "Hello world")]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert result[0] == {"role": "user", "content": "Hello world"}
+
+
+def test_convert_user_message_list_text():
+ content = [
+ MockBlock(type="text", text="Hello"),
+ MockBlock(type="text", text="World"),
+ ]
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert result[0] == {"role": "user", "content": "Hello\nWorld"}
+
+
+def test_convert_user_message_tool_result_str():
+ content = [
+ MockBlock(type="tool_result", tool_use_id="tool_123", content="Result data")
+ ]
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert result[0] == {
+ "role": "tool",
+ "tool_call_id": "tool_123",
+ "content": "Result data",
+ }
+
+
+def test_convert_user_message_tool_result_list():
+ # Tool result content as a list of text blocks
+ tool_content = [
+ {"type": "text", "text": "Line 1"},
+ {"type": "text", "text": "Line 2"},
+ ]
+ content = [
+ MockBlock(type="tool_result", tool_use_id="tool_456", content=tool_content)
+ ]
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert result[0]["role"] == "tool"
+ assert result[0]["tool_call_id"] == "tool_456"
+ assert result[0]["content"] == "Line 1\nLine 2"
+
+
+def test_convert_user_message_mixed_text_and_tool_result():
+ # Note: Anthropic/OpenAI mapping usually separates these, but the converter handles lists
+ # User text usually comes before tool results in a turn, or after.
+ # The converter splits them into separate messages if they are different roles?
+ # Let's check logic: _convert_user_message returns a list of dicts.
+ content = [
+ MockBlock(type="text", text="Here is the result:"),
+ MockBlock(type="tool_result", tool_use_id="tool_789", content="42"),
+ ]
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ # Order is preserved: user text first, then tool result.
+ assert len(result) == 2
+ assert result[0] == {"role": "user", "content": "Here is the result:"}
+ assert result[1] == {"role": "tool", "tool_call_id": "tool_789", "content": "42"}
+
+
+# --- Message Conversion Tests: Assistant ---
+
+
+def test_convert_assistant_message_text_only():
+ messages = [MockMessage("assistant", "I am ready.")]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert result[0] == {"role": "assistant", "content": "I am ready."}
+
+
+def test_convert_assistant_message_blocks_text():
+ content = [MockBlock(type="text", text="Part A")]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert result[0] == {"role": "assistant", "content": "Part A"}
+
+
+def test_convert_assistant_message_thinking():
+ content = [
+ MockBlock(type="thinking", thinking="I need to calculate this."),
+ MockBlock(type="text", text="The answer is 4."),
+ ]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ assert len(result) == 1
+ # Expecting tags
+ expected_content = (
+ "\nI need to calculate this.\n\n\nThe answer is 4."
+ )
+ assert result[0]["content"] == expected_content
+ assert "reasoning_content" not in result[0]
+
+
+def test_convert_assistant_message_thinking_include_reasoning_for_openrouter():
+ """When include_reasoning_for_openrouter=True, reasoning_content is added."""
+ content = [
+ MockBlock(type="thinking", thinking="I need to calculate this."),
+ MockBlock(type="text", text="The answer is 4."),
+ ]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(
+ messages, include_reasoning_for_openrouter=True
+ )
+
+ assert len(result) == 1
+ assert result[0]["reasoning_content"] == "I need to calculate this."
+ assert "" in result[0]["content"]
+
+
+def test_convert_assistant_message_tool_use():
+ content = [
+ MockBlock(type="text", text="I will call the tool."),
+ MockBlock(
+ type="tool_use", id="call_1", name="search", input={"query": "python"}
+ ),
+ ]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ assert len(result) == 1
+ msg = result[0]
+ assert msg["role"] == "assistant"
+ assert "I will call the tool." in msg["content"]
+ assert "tool_calls" in msg
+ assert len(msg["tool_calls"]) == 1
+ tc = msg["tool_calls"][0]
+ assert tc["id"] == "call_1"
+ assert tc["function"]["name"] == "search"
+ assert json.loads(tc["function"]["arguments"]) == {"query": "python"}
+
+
+def test_convert_assistant_message_empty_content():
+ # Verify that empty content becomes a single space (NIM requirement)
+ # if no tool calls are present.
+ content = []
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert result[0]["content"] == " "
+
+
+def test_convert_assistant_message_tool_use_no_text():
+ # If tool usage exists, content can be empty string?
+ # Logic: if not content_str and not tool_calls: content_str = " "
+ # So if tool_calls exist, content_str can be empty string?
+ # Actually code says: if not content_str and not tool_calls.
+ # So if tool_calls is present, content_str remains "" (empty).
+
+ content = [MockBlock(type="tool_use", id="call_2", name="test", input={})]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ assert (
+ result[0]["content"] == ""
+ ) # Should be empty string, not space, because tools exist
+ assert len(result[0]["tool_calls"]) == 1
+
+
+def test_convert_mixed_blocks_and_types_and_roles():
+ # comprehensive flow
+ messages = [
+ MockMessage("user", "Start"),
+ MockMessage(
+ "assistant",
+ [
+ MockBlock(type="thinking", thinking="Thinking..."),
+ MockBlock(type="text", text="Here is a tool."),
+ ],
+ ),
+ MockMessage(
+ "assistant", [MockBlock(type="tool_use", id="t1", name="f", input={})]
+ ),
+ ]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ assert len(result) == 3
+ assert result[0]["role"] == "user"
+ assert "" in result[1]["content"]
+ assert result[2]["tool_calls"][0]["id"] == "t1"
+
+
+# --- Edge Cases ---
+
+
+def test_get_block_attr_defaults():
+ # Test helper directly
+ from providers.common.message_converter import get_block_attr
+
+ assert get_block_attr({}, "missing", "default") == "default"
+ assert get_block_attr(object(), "missing", "default") == "default"
+
+
+def test_input_not_dict():
+ # Tool input might not be a dict (e.g. malformed or string)
+ content = [MockBlock(type="tool_use", id="call_x", name="f", input="some_string")]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ # The converter calls json.dumps(tool_input) if dict, else str(tool_input)
+ # So it should be "some_string"
+ assert result[0]["tool_calls"][0]["function"]["arguments"] == "some_string"
+
+
+# --- Parametrized Edge Case Tests ---
+
+
+@pytest.mark.parametrize(
+ "system_input,expected",
+ [
+ ("You are helpful.", {"role": "system", "content": "You are helpful."}),
+ (
+ [MockBlock(type="text", text="A"), MockBlock(type="text", text="B")],
+ {"role": "system", "content": "A\n\nB"},
+ ),
+ (None, None),
+ ("", {"role": "system", "content": ""}),
+ ([], None),
+ ],
+ ids=["string", "list_text", "none", "empty_string", "empty_list"],
+)
+def test_convert_system_prompt_parametrized(system_input, expected):
+ """Parametrized system prompt conversion covering edge cases."""
+ result = AnthropicToOpenAIConverter.convert_system_prompt(system_input)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "content,expected_content",
+ [
+ ("Hello world", "Hello world"),
+ ("", ""),
+ ([MockBlock(type="text", text="A"), MockBlock(type="text", text="B")], "A\nB"),
+ ([MockBlock(type="text", text="")], ""),
+ ],
+ ids=["simple_string", "empty_string", "list_blocks", "empty_text_block"],
+)
+def test_convert_user_message_parametrized(content, expected_content):
+ """Parametrized user message conversion."""
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) >= 1
+ assert result[0]["content"] == expected_content
+
+
+def test_convert_assistant_message_unknown_block_type():
+ """Unknown block types should be silently skipped."""
+ content = [
+ MockBlock(type="unknown_type", data="something"),
+ MockBlock(type="text", text="visible"),
+ ]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert "visible" in result[0]["content"]
+
+
+def test_convert_tool_use_none_input():
+ """Tool use with None input should not crash."""
+ content = [MockBlock(type="tool_use", id="call_n", name="test", input=None)]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 1
+ assert "tool_calls" in result[0]
+
+
+def test_convert_assistant_interleaved_order_preserved():
+ """Interleaved thinking, text, tool_use should preserve thinking+text order in content.
+
+ Bug: Current implementation collects all thinking, then all text, then tool_calls.
+ Original order [thinking, text, thinking, tool_use] becomes [all thinking, all text, tool_calls],
+ losing the interleaving. Content string should reflect original block order for thinking+text.
+ Tool calls stay at end (API constraint).
+ """
+ content = [
+ MockBlock(type="thinking", thinking="First thought."),
+ MockBlock(type="text", text="Here is the answer."),
+ MockBlock(type="thinking", thinking="Second thought."),
+ MockBlock(type="tool_use", id="call_1", name="search", input={"q": "x"}),
+ ]
+ messages = [MockMessage("assistant", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ assert len(result) == 1
+ msg = result[0]
+ # Expected: thinking1, text, thinking2 in that order within content; tool_calls at end
+ expected_content = "\nFirst thought.\n\n\nHere is the answer.\n\n\nSecond thought.\n"
+ assert msg["content"] == expected_content, (
+ f"Interleaved order lost. Got: {msg['content']!r}"
+ )
+ assert len(msg["tool_calls"]) == 1
+
+
+def test_convert_user_message_text_before_tool_result_order():
+ """User message with text then tool_result should preserve order: user text first, then tool.
+
+ Bug: Current implementation emits tool_result immediately, then user text at end.
+ Anthropic order is typically: user says something, then provides tool results.
+ """
+ content = [
+ MockBlock(type="text", text="Please use this result:"),
+ MockBlock(type="tool_result", tool_use_id="t1", content="42"),
+ ]
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+
+ assert len(result) == 2
+ # Expected: user text first, then tool result
+ assert result[0]["role"] == "user"
+ assert result[0]["content"] == "Please use this result:"
+ assert result[1]["role"] == "tool"
+ assert result[1]["tool_call_id"] == "t1"
+
+
+def test_convert_multiple_tool_results():
+ """Multiple tool results in a single user message."""
+ content = [
+ MockBlock(type="tool_result", tool_use_id="t1", content="Result 1"),
+ MockBlock(type="tool_result", tool_use_id="t2", content="Result 2"),
+ ]
+ messages = [MockMessage("user", content)]
+ result = AnthropicToOpenAIConverter.convert_messages(messages)
+ assert len(result) == 2
+ assert result[0]["tool_call_id"] == "t1"
+ assert result[1]["tool_call_id"] == "t2"
diff --git a/Claude_Code/tests/providers/test_error_mapping.py b/Claude_Code/tests/providers/test_error_mapping.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e6cf99573a95fc153d3df899958954cf2947302
--- /dev/null
+++ b/Claude_Code/tests/providers/test_error_mapping.py
@@ -0,0 +1,135 @@
+"""Tests for providers/nvidia_nim/errors.py error mapping."""
+
+from unittest.mock import MagicMock, patch
+
+import openai
+import pytest
+from httpx import ReadTimeout, Request, Response
+
+from providers.common import append_request_id, get_user_facing_error_message, map_error
+from providers.exceptions import (
+ APIError,
+ AuthenticationError,
+ InvalidRequestError,
+ OverloadedError,
+ RateLimitError,
+)
+
+
+def _make_openai_error(cls, message="test error", status_code=None):
+ """Helper to create openai exceptions with required httpx objects."""
+ response = Response(
+ status_code=status_code or 500, request=Request("POST", "http://test")
+ )
+ body = {"error": {"message": message}}
+ # openai.APIError base class has a different constructor signature
+ if cls is openai.APIError:
+ return cls(message, request=Request("POST", "http://test"), body=body)
+ return cls(message, response=response, body=body)
+
+
+class TestMapError:
+ """Tests for map_error function."""
+
+ def test_authentication_error(self):
+ """openai.AuthenticationError -> AuthenticationError."""
+ exc = _make_openai_error(openai.AuthenticationError, status_code=401)
+ result = map_error(exc)
+ assert isinstance(result, AuthenticationError)
+ assert result.status_code == 401
+
+ def test_rate_limit_error(self):
+ """openai.RateLimitError -> RateLimitError and triggers global block."""
+ exc = _make_openai_error(openai.RateLimitError, status_code=429)
+ with patch("providers.common.error_mapping.GlobalRateLimiter") as mock_rl:
+ mock_instance = MagicMock()
+ mock_rl.get_instance.return_value = mock_instance
+ result = map_error(exc)
+ assert isinstance(result, RateLimitError)
+ assert result.status_code == 429
+ mock_instance.set_blocked.assert_called_once_with(60)
+
+ def test_bad_request_error(self):
+ """openai.BadRequestError -> InvalidRequestError."""
+ exc = _make_openai_error(openai.BadRequestError, status_code=400)
+ result = map_error(exc)
+ assert isinstance(result, InvalidRequestError)
+ assert result.status_code == 400
+
+ @pytest.mark.parametrize(
+ "message",
+ ["Server overloaded", "No capacity available"],
+ ids=["overloaded", "capacity"],
+ )
+ def test_internal_server_error_overloaded(self, message):
+ """InternalServerError with overloaded/capacity keywords -> OverloadedError."""
+ exc = _make_openai_error(
+ openai.InternalServerError, message=message, status_code=500
+ )
+ result = map_error(exc)
+ assert isinstance(result, OverloadedError)
+ assert result.status_code == 529
+
+ def test_internal_server_error_generic(self):
+ """InternalServerError without keywords -> APIError(500)."""
+ exc = _make_openai_error(
+ openai.InternalServerError, message="Unknown error", status_code=500
+ )
+ result = map_error(exc)
+ assert isinstance(result, APIError)
+ assert result.status_code == 500
+
+ def test_generic_api_error(self):
+ """openai.APIError -> APIError with original status_code."""
+ exc = _make_openai_error(
+ openai.APIError, message="Bad gateway", status_code=502
+ )
+ result = map_error(exc)
+ assert isinstance(result, APIError)
+
+ def test_unmapped_exception_passthrough(self):
+ """Non-openai exceptions are returned as-is."""
+ exc = RuntimeError("unexpected")
+ result = map_error(exc)
+ assert result is exc
+ assert isinstance(result, RuntimeError)
+
+ def test_value_error_passthrough(self):
+ """ValueError passes through unchanged."""
+ exc = ValueError("bad value")
+ result = map_error(exc)
+ assert result is exc
+
+ @pytest.mark.parametrize(
+ "exc_cls,expected_cls",
+ [
+ (openai.AuthenticationError, AuthenticationError),
+ (openai.RateLimitError, RateLimitError),
+ (openai.BadRequestError, InvalidRequestError),
+ ],
+ ids=["auth", "rate_limit", "bad_request"],
+ )
+ def test_mapping_parametrized(self, exc_cls, expected_cls):
+ """Parametrized check of openai -> provider error mapping."""
+ status_map = {
+ openai.AuthenticationError: 401,
+ openai.RateLimitError: 429,
+ openai.BadRequestError: 400,
+ }
+ exc = _make_openai_error(exc_cls, status_code=status_map[exc_cls])
+ with patch("providers.common.error_mapping.GlobalRateLimiter"):
+ result = map_error(exc)
+ assert isinstance(result, expected_cls)
+
+
+def test_user_facing_message_read_timeout_empty_string():
+ """ReadTimeout wrapping TimeoutError should still produce readable text."""
+ timeout_exc = ReadTimeout("")
+ message = get_user_facing_error_message(timeout_exc, read_timeout_s=60)
+ assert message == "Provider request timed out after 60s."
+
+
+def test_append_request_id_suffix():
+ """Request id suffix should be appended deterministically."""
+ message = append_request_id("Provider request failed.", "req_abc123")
+ assert message == "Provider request failed. (request_id=req_abc123)"
diff --git a/Claude_Code/tests/providers/test_llamacpp.py b/Claude_Code/tests/providers/test_llamacpp.py
new file mode 100644
index 0000000000000000000000000000000000000000..97463b87a5a8ce8d06bb4e3c403ce99941878f98
--- /dev/null
+++ b/Claude_Code/tests/providers/test_llamacpp.py
@@ -0,0 +1,256 @@
+"""Tests for Llama.cpp native Anthropic provider."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from providers.base import ProviderConfig
+from providers.llamacpp import LlamaCppProvider
+
+
+class MockMessage:
+ def __init__(self, role, content):
+ self.role = role
+ self.content = content
+
+
+class MockRequest:
+ def __init__(self, **kwargs):
+ self.model = "llamacpp-community/qwen2.5-7b-instruct"
+ self.messages = [MockMessage("user", "Hello")]
+ self.max_tokens = 100
+ self.temperature = 0.5
+ self.top_p = 0.9
+ self.system = "System prompt"
+ self.stop_sequences = None
+ self.tools = []
+ self.extra_body = {}
+ self.thinking = MagicMock()
+ self.thinking.enabled = True
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+ def model_dump(self, exclude_none=True):
+ return {
+ "model": self.model,
+ "messages": [{"role": m.role, "content": m.content} for m in self.messages],
+ "max_tokens": self.max_tokens,
+ "temperature": self.temperature,
+ "extra_body": self.extra_body,
+ "thinking": {"enabled": self.thinking.enabled} if self.thinking else None,
+ }
+
+
+@pytest.fixture
+def llamacpp_config():
+ return ProviderConfig(
+ api_key="llamacpp",
+ base_url="http://localhost:8080/v1",
+ rate_limit=10,
+ rate_window=60,
+ )
+
+
+@pytest.fixture(autouse=True)
+def mock_rate_limiter():
+ """Mock the global rate limiter to prevent waiting."""
+ with patch("providers.llamacpp.client.GlobalRateLimiter") as mock:
+ instance = mock.get_instance.return_value
+ instance.wait_if_blocked = AsyncMock(return_value=False)
+
+ async def _passthrough(fn, *args, **kwargs):
+ return await fn(*args, **kwargs)
+
+ instance.execute_with_retry = AsyncMock(side_effect=_passthrough)
+ yield instance
+
+
+@pytest.fixture
+def llamacpp_provider(llamacpp_config):
+ return LlamaCppProvider(llamacpp_config)
+
+
+def test_init(llamacpp_config):
+ """Test provider initialization."""
+ with patch("httpx.AsyncClient"):
+ provider = LlamaCppProvider(llamacpp_config)
+ assert provider._base_url == "http://localhost:8080/v1"
+ assert provider._provider_name == "LLAMACPP"
+
+
+def test_init_uses_configurable_timeouts():
+ """Test that provider passes configurable read/write/connect timeouts to client."""
+ config = ProviderConfig(
+ api_key="llamacpp",
+ base_url="http://localhost:8080/v1",
+ http_read_timeout=600.0,
+ http_write_timeout=15.0,
+ http_connect_timeout=5.0,
+ )
+ with patch("httpx.AsyncClient") as mock_client:
+ LlamaCppProvider(config)
+ call_kwargs = mock_client.call_args[1]
+ timeout = call_kwargs["timeout"]
+ assert timeout.read == 600.0
+ assert timeout.write == 15.0
+ assert timeout.connect == 5.0
+
+
+def test_init_base_url_strips_trailing_slash():
+ """Config with base_url trailing slash is stored without it."""
+ config = ProviderConfig(
+ api_key="llamacpp",
+ base_url="http://localhost:8080/v1/",
+ rate_limit=10,
+ rate_window=60,
+ )
+ with patch("httpx.AsyncClient"):
+ provider = LlamaCppProvider(config)
+ assert provider._base_url == "http://localhost:8080/v1"
+
+
+@pytest.mark.asyncio
+async def test_stream_response(llamacpp_provider):
+ """Test streaming native Anthropic response."""
+ req = MockRequest()
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+
+ async def mock_aiter_lines():
+ yield "event: message_start"
+ yield 'data: {"type":"message_start","message":{}}'
+ yield ""
+ yield "event: content_block_delta"
+ yield 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello World"}}'
+ yield ""
+ yield "event: message_stop"
+ yield 'data: {"type":"message_stop"}'
+ yield ""
+
+ mock_response.aiter_lines = mock_aiter_lines
+
+ with (
+ patch.object(
+ llamacpp_provider._client, "build_request", return_value=MagicMock()
+ ) as mock_build,
+ patch.object(
+ llamacpp_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ return_value=mock_response,
+ ),
+ ):
+ events = [e async for e in llamacpp_provider.stream_response(req)]
+
+ # Verify request construction
+ mock_build.assert_called_once()
+ args, kwargs = mock_build.call_args
+ assert args[0] == "POST"
+ assert args[1] == "/messages"
+ assert kwargs["json"]["model"] == "llamacpp-community/qwen2.5-7b-instruct"
+ # Verify internal fields are popped
+ assert "extra_body" not in kwargs["json"]
+ assert kwargs["json"]["max_tokens"] == 100
+
+ # Verify internal ThinkingConfig is mapped to Anthropic API format
+ assert kwargs["json"]["thinking"] == {"type": "enabled"}
+
+ # Verify events yielded correctly
+ assert len(events) == 9
+ assert events[0] == "event: message_start\n"
+ assert events[1] == 'data: {"type":"message_start","message":{}}\n'
+
+
+@pytest.mark.asyncio
+async def test_stream_response_adds_max_tokens_if_missing(llamacpp_provider):
+ """Fallback max_tokens to 81920 if not present."""
+ req = MockRequest()
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+
+ async def empty_aiter():
+ if False:
+ yield ""
+
+ mock_response.aiter_lines = empty_aiter
+
+ with (
+ patch.object(req, "model_dump", return_value={"model": "test"}),
+ patch.object(llamacpp_provider._client, "build_request") as mock_build,
+ patch.object(
+ llamacpp_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ return_value=mock_response,
+ ),
+ ):
+ # Just run the generator to completion
+ [e async for e in llamacpp_provider.stream_response(req)]
+
+ _, kwargs = mock_build.call_args
+ assert kwargs["json"]["max_tokens"] == 81920
+
+
+@pytest.mark.asyncio
+async def test_stream_error_status_code(llamacpp_provider):
+ """Non-200 status code raises an error that gets caught and yielded as an SSE API error."""
+ req = MockRequest()
+
+ mock_response = MagicMock()
+ mock_response.status_code = 500
+ mock_response.aread = AsyncMock(return_value=b"Internal Server Error")
+ mock_response.raise_for_status = MagicMock(
+ side_effect=httpx.HTTPStatusError(
+ "Internal Server Error", request=MagicMock(), response=mock_response
+ )
+ )
+
+ with (
+ patch.object(
+ llamacpp_provider._client, "build_request", return_value=MagicMock()
+ ),
+ patch.object(
+ llamacpp_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ return_value=mock_response,
+ ),
+ ):
+ events = [
+ e
+ async for e in llamacpp_provider.stream_response(req, request_id="TEST_ID")
+ ]
+
+ assert len(events) == 1
+ assert events[0].startswith("event: error\ndata: {")
+ assert "Internal Server Error" in events[0]
+ assert "TEST_ID" in events[0]
+
+
+@pytest.mark.asyncio
+async def test_stream_network_error(llamacpp_provider):
+ """Network errors are caught and yielded as SSE API error events."""
+ req = MockRequest()
+
+ with (
+ patch.object(
+ llamacpp_provider._client, "build_request", return_value=MagicMock()
+ ),
+ patch.object(
+ llamacpp_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ side_effect=httpx.ConnectError("Connection refused"),
+ ),
+ ):
+ events = [
+ e
+ async for e in llamacpp_provider.stream_response(req, request_id="TEST_ID2")
+ ]
+
+ assert len(events) == 1
+ assert events[0].startswith("event: error\ndata: {")
+ assert "Connection refused" in events[0]
+ assert "TEST_ID2" in events[0]
diff --git a/Claude_Code/tests/providers/test_lmstudio.py b/Claude_Code/tests/providers/test_lmstudio.py
new file mode 100644
index 0000000000000000000000000000000000000000..201e59100d71e49a7c24aaacf4ac7b6fb9e71a53
--- /dev/null
+++ b/Claude_Code/tests/providers/test_lmstudio.py
@@ -0,0 +1,256 @@
+"""Tests for LM Studio native Anthropic provider."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from providers.base import ProviderConfig
+from providers.lmstudio import LMStudioProvider
+
+
+class MockMessage:
+ def __init__(self, role, content):
+ self.role = role
+ self.content = content
+
+
+class MockRequest:
+ def __init__(self, **kwargs):
+ self.model = "lmstudio-community/qwen2.5-7b-instruct"
+ self.messages = [MockMessage("user", "Hello")]
+ self.max_tokens = 100
+ self.temperature = 0.5
+ self.top_p = 0.9
+ self.system = "System prompt"
+ self.stop_sequences = None
+ self.tools = []
+ self.extra_body = {}
+ self.thinking = MagicMock()
+ self.thinking.enabled = True
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+ def model_dump(self, exclude_none=True):
+ return {
+ "model": self.model,
+ "messages": [{"role": m.role, "content": m.content} for m in self.messages],
+ "max_tokens": self.max_tokens,
+ "temperature": self.temperature,
+ "extra_body": self.extra_body,
+ "thinking": {"enabled": self.thinking.enabled} if self.thinking else None,
+ }
+
+
+@pytest.fixture
+def lmstudio_config():
+ return ProviderConfig(
+ api_key="lm-studio",
+ base_url="http://localhost:1234/v1",
+ rate_limit=10,
+ rate_window=60,
+ )
+
+
+@pytest.fixture(autouse=True)
+def mock_rate_limiter():
+ """Mock the global rate limiter to prevent waiting."""
+ with patch("providers.lmstudio.client.GlobalRateLimiter") as mock:
+ instance = mock.get_instance.return_value
+ instance.wait_if_blocked = AsyncMock(return_value=False)
+
+ async def _passthrough(fn, *args, **kwargs):
+ return await fn(*args, **kwargs)
+
+ instance.execute_with_retry = AsyncMock(side_effect=_passthrough)
+ yield instance
+
+
+@pytest.fixture
+def lmstudio_provider(lmstudio_config):
+ return LMStudioProvider(lmstudio_config)
+
+
+def test_init(lmstudio_config):
+ """Test provider initialization."""
+ with patch("httpx.AsyncClient"):
+ provider = LMStudioProvider(lmstudio_config)
+ assert provider._base_url == "http://localhost:1234/v1"
+ assert provider._provider_name == "LMSTUDIO"
+
+
+def test_init_uses_configurable_timeouts():
+ """Test that provider passes configurable read/write/connect timeouts to client."""
+ config = ProviderConfig(
+ api_key="lm-studio",
+ base_url="http://localhost:1234/v1",
+ http_read_timeout=600.0,
+ http_write_timeout=15.0,
+ http_connect_timeout=5.0,
+ )
+ with patch("httpx.AsyncClient") as mock_client:
+ LMStudioProvider(config)
+ call_kwargs = mock_client.call_args[1]
+ timeout = call_kwargs["timeout"]
+ assert timeout.read == 600.0
+ assert timeout.write == 15.0
+ assert timeout.connect == 5.0
+
+
+def test_init_base_url_strips_trailing_slash():
+ """Config with base_url trailing slash is stored without it."""
+ config = ProviderConfig(
+ api_key="lm-studio",
+ base_url="http://localhost:1234/v1/",
+ rate_limit=10,
+ rate_window=60,
+ )
+ with patch("httpx.AsyncClient"):
+ provider = LMStudioProvider(config)
+ assert provider._base_url == "http://localhost:1234/v1"
+
+
+@pytest.mark.asyncio
+async def test_stream_response(lmstudio_provider):
+ """Test streaming native Anthropic response."""
+ req = MockRequest()
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+
+ async def mock_aiter_lines():
+ yield "event: message_start"
+ yield 'data: {"type":"message_start","message":{}}'
+ yield ""
+ yield "event: content_block_delta"
+ yield 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello World"}}'
+ yield ""
+ yield "event: message_stop"
+ yield 'data: {"type":"message_stop"}'
+ yield ""
+
+ mock_response.aiter_lines = mock_aiter_lines
+
+ with (
+ patch.object(
+ lmstudio_provider._client, "build_request", return_value=MagicMock()
+ ) as mock_build,
+ patch.object(
+ lmstudio_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ return_value=mock_response,
+ ),
+ ):
+ events = [e async for e in lmstudio_provider.stream_response(req)]
+
+ # Verify request construction
+ mock_build.assert_called_once()
+ args, kwargs = mock_build.call_args
+ assert args[0] == "POST"
+ assert args[1] == "/messages"
+ assert kwargs["json"]["model"] == "lmstudio-community/qwen2.5-7b-instruct"
+ # Verify internal fields are popped
+ assert "extra_body" not in kwargs["json"]
+ assert kwargs["json"]["max_tokens"] == 100
+
+ # Verify internal ThinkingConfig is mapped to Anthropic API format
+ assert kwargs["json"]["thinking"] == {"type": "enabled"}
+
+ # Verify events yielded correctly
+ assert len(events) == 9
+ assert events[0] == "event: message_start\n"
+ assert events[1] == 'data: {"type":"message_start","message":{}}\n'
+
+
+@pytest.mark.asyncio
+async def test_stream_response_adds_max_tokens_if_missing(lmstudio_provider):
+ """Fallback max_tokens to 81920 if not present."""
+ req = MockRequest()
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+
+ async def empty_aiter():
+ if False:
+ yield ""
+
+ mock_response.aiter_lines = empty_aiter
+
+ with (
+ patch.object(req, "model_dump", return_value={"model": "test"}),
+ patch.object(lmstudio_provider._client, "build_request") as mock_build,
+ patch.object(
+ lmstudio_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ return_value=mock_response,
+ ),
+ ):
+ # Just run the generator to completion
+ [e async for e in lmstudio_provider.stream_response(req)]
+
+ _, kwargs = mock_build.call_args
+ assert kwargs["json"]["max_tokens"] == 81920
+
+
+@pytest.mark.asyncio
+async def test_stream_error_status_code(lmstudio_provider):
+ """Non-200 status code raises an error that gets caught and yielded as an SSE API error."""
+ req = MockRequest()
+
+ mock_response = MagicMock()
+ mock_response.status_code = 500
+ mock_response.aread = AsyncMock(return_value=b"Internal Server Error")
+ mock_response.raise_for_status = MagicMock(
+ side_effect=httpx.HTTPStatusError(
+ "Internal Server Error", request=MagicMock(), response=mock_response
+ )
+ )
+
+ with (
+ patch.object(
+ lmstudio_provider._client, "build_request", return_value=MagicMock()
+ ),
+ patch.object(
+ lmstudio_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ return_value=mock_response,
+ ),
+ ):
+ events = [
+ e
+ async for e in lmstudio_provider.stream_response(req, request_id="TEST_ID")
+ ]
+
+ assert len(events) == 1
+ assert events[0].startswith("event: error\ndata: {")
+ assert "Internal Server Error" in events[0]
+ assert "TEST_ID" in events[0]
+
+
+@pytest.mark.asyncio
+async def test_stream_network_error(lmstudio_provider):
+ """Network errors are caught and yielded as SSE API error events."""
+ req = MockRequest()
+
+ with (
+ patch.object(
+ lmstudio_provider._client, "build_request", return_value=MagicMock()
+ ),
+ patch.object(
+ lmstudio_provider._client,
+ "send",
+ new_callable=AsyncMock,
+ side_effect=httpx.ConnectError("Connection refused"),
+ ),
+ ):
+ events = [
+ e
+ async for e in lmstudio_provider.stream_response(req, request_id="TEST_ID2")
+ ]
+
+ assert len(events) == 1
+ assert events[0].startswith("event: error\ndata: {")
+ assert "Connection refused" in events[0]
+ assert "TEST_ID2" in events[0]
diff --git a/Claude_Code/tests/providers/test_nvidia_nim.py b/Claude_Code/tests/providers/test_nvidia_nim.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4a136e33a35e868446e5c69deb8ce8eb714e598
--- /dev/null
+++ b/Claude_Code/tests/providers/test_nvidia_nim.py
@@ -0,0 +1,233 @@
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from providers.nvidia_nim import NvidiaNimProvider
+
+
+# Mock data classes
+class MockMessage:
+ def __init__(self, role, content):
+ self.role = role
+ self.content = content
+
+
+class MockTool:
+ def __init__(self, name, description, input_schema):
+ self.name = name
+ self.description = description
+ self.input_schema = input_schema
+
+
+class MockRequest:
+ def __init__(self, **kwargs):
+ self.model = "test-model"
+ self.messages = [MockMessage("user", "Hello")]
+ self.max_tokens = 100
+ self.temperature = 0.5
+ self.top_p = 0.9
+ self.system = "System prompt"
+ self.stop_sequences = ["STOP"]
+ self.tools = []
+ self.extra_body = {}
+ self.thinking = MagicMock()
+ self.thinking.enabled = True
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+
+@pytest.fixture(autouse=True)
+def mock_rate_limiter():
+ """Mock the global rate limiter to prevent waiting."""
+ with patch("providers.openai_compat.GlobalRateLimiter") as mock:
+ instance = mock.get_instance.return_value
+ instance.wait_if_blocked = AsyncMock(return_value=False)
+
+ # execute_with_retry should call through to the actual function
+ async def _passthrough(fn, *args, **kwargs):
+ return await fn(*args, **kwargs)
+
+ instance.execute_with_retry = AsyncMock(side_effect=_passthrough)
+ yield instance
+
+
+@pytest.mark.asyncio
+async def test_init(provider_config):
+ """Test provider initialization."""
+ with patch("providers.openai_compat.AsyncOpenAI") as mock_openai:
+ from config.nim import NimSettings
+
+ provider = NvidiaNimProvider(provider_config, nim_settings=NimSettings())
+ assert provider._api_key == "test_key"
+ assert provider._base_url == "https://test.api.nvidia.com/v1"
+ mock_openai.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_uses_configurable_timeouts():
+ """Test that provider passes configurable read/write/connect timeouts to client."""
+ from config.nim import NimSettings
+ from providers.base import ProviderConfig
+
+ config = ProviderConfig(
+ api_key="test_key",
+ base_url="https://test.api.nvidia.com/v1",
+ http_read_timeout=600.0,
+ http_write_timeout=15.0,
+ http_connect_timeout=5.0,
+ )
+ with patch("providers.openai_compat.AsyncOpenAI") as mock_openai:
+ NvidiaNimProvider(config, nim_settings=NimSettings())
+ call_kwargs = mock_openai.call_args[1]
+ timeout = call_kwargs["timeout"]
+ assert timeout.read == 600.0
+ assert timeout.write == 15.0
+ assert timeout.connect == 5.0
+
+
+@pytest.mark.asyncio
+async def test_build_request_body(provider_config):
+ """Test request body construction."""
+ from config.nim import NimSettings
+
+ provider = NvidiaNimProvider(
+ provider_config, nim_settings=NimSettings(enable_thinking=True)
+ )
+ req = MockRequest()
+ body = provider._build_request_body(req)
+
+ assert body["model"] == "test-model"
+ assert body["temperature"] == 0.5
+ assert len(body["messages"]) == 2 # System + User
+ assert body["messages"][0]["role"] == "system"
+ assert body["messages"][0]["content"] == "System prompt"
+
+ assert "extra_body" in body
+ ctk = body["extra_body"]["chat_template_kwargs"]
+ assert ctk["thinking"] is True
+ assert ctk["enable_thinking"] is True
+ assert body["extra_body"]["reasoning_budget"] == body["max_tokens"]
+
+
+@pytest.mark.asyncio
+async def test_stream_response_text(nim_provider):
+ """Test streaming text response."""
+ req = MockRequest()
+
+ # Create mock chunks
+ mock_chunk1 = MagicMock()
+ mock_chunk1.choices = [
+ MagicMock(
+ delta=MagicMock(content="Hello", reasoning_content=""), finish_reason=None
+ )
+ ]
+ mock_chunk1.usage = None
+
+ mock_chunk2 = MagicMock()
+ mock_chunk2.choices = [
+ MagicMock(
+ delta=MagicMock(content=" World", reasoning_content=""),
+ finish_reason="stop",
+ )
+ ]
+ mock_chunk2.usage = MagicMock(completion_tokens=10)
+
+ async def mock_stream():
+ yield mock_chunk1
+ yield mock_chunk2
+
+ with patch.object(
+ nim_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+
+ events = [e async for e in nim_provider.stream_response(req)]
+
+ assert len(events) > 0
+ assert "event: message_start" in events[0]
+
+ text_content = ""
+ for e in events:
+ if "event: content_block_delta" in e and '"text_delta"' in e:
+ for line in e.splitlines():
+ if line.startswith("data: "):
+ data = json.loads(line[6:])
+ if "delta" in data and "text" in data["delta"]:
+ text_content += data["delta"]["text"]
+
+ assert "Hello World" in text_content
+
+
+@pytest.mark.asyncio
+async def test_stream_response_thinking_reasoning_content(nim_provider):
+ """Test streaming with native reasoning_content."""
+ req = MockRequest()
+
+ mock_chunk = MagicMock()
+ mock_chunk.choices = [
+ MagicMock(
+ delta=MagicMock(content=None, reasoning_content="Thinking..."),
+ finish_reason=None,
+ )
+ ]
+ mock_chunk.usage = None
+
+ async def mock_stream():
+ yield mock_chunk
+
+ with patch.object(
+ nim_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+
+ events = [e async for e in nim_provider.stream_response(req)]
+
+ # Check for thinking_delta
+ found_thinking = False
+ for e in events:
+ if (
+ "event: content_block_delta" in e
+ and '"thinking_delta"' in e
+ and "Thinking..." in e
+ ):
+ found_thinking = True
+ assert found_thinking
+
+
+@pytest.mark.asyncio
+async def test_tool_call_stream(nim_provider):
+ """Test streaming tool calls."""
+ req = MockRequest()
+
+ # Mock tool call delta
+ mock_tc = MagicMock()
+ mock_tc.index = 0
+ mock_tc.id = "call_1"
+ mock_tc.function.name = "search"
+ mock_tc.function.arguments = '{"q": "test"}'
+
+ mock_chunk = MagicMock()
+ mock_chunk.choices = [
+ MagicMock(
+ delta=MagicMock(content=None, reasoning_content="", tool_calls=[mock_tc]),
+ finish_reason=None,
+ )
+ ]
+ mock_chunk.usage = None
+
+ async def mock_stream():
+ yield mock_chunk
+
+ with patch.object(
+ nim_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+
+ events = [e async for e in nim_provider.stream_response(req)]
+
+ starts = [
+ e for e in events if "event: content_block_start" in e and '"tool_use"' in e
+ ]
+ assert len(starts) == 1
+ assert "search" in starts[0]
diff --git a/Claude_Code/tests/providers/test_nvidia_nim_request.py b/Claude_Code/tests/providers/test_nvidia_nim_request.py
new file mode 100644
index 0000000000000000000000000000000000000000..85454232b0a0baeba19f6389670b11cb56fe69d3
--- /dev/null
+++ b/Claude_Code/tests/providers/test_nvidia_nim_request.py
@@ -0,0 +1,154 @@
+"""Tests for providers/nvidia_nim/request.py."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from config.nim import NimSettings
+from providers.common.utils import set_if_not_none
+from providers.nvidia_nim.request import (
+ _set_extra,
+ build_request_body,
+)
+
+
+@pytest.fixture
+def req():
+ r = MagicMock()
+ r.model = "test"
+ r.messages = [MagicMock(role="user", content="hi")]
+ r.max_tokens = 100
+ r.system = None
+ r.temperature = None
+ r.top_p = None
+ r.stop_sequences = None
+ r.tools = None
+ r.tool_choice = None
+ r.extra_body = None
+ r.top_k = None
+ return r
+
+
+class TestSetIfNotNone:
+ def test_value_not_none_sets(self):
+ body = {}
+ set_if_not_none(body, "key", "value")
+ assert body["key"] == "value"
+
+ def test_value_none_skips(self):
+ body = {}
+ set_if_not_none(body, "key", None)
+ assert "key" not in body
+
+
+class TestSetExtra:
+ def test_key_in_extra_body_skips(self):
+ extra = {"top_k": 42}
+ _set_extra(extra, "top_k", 10)
+ assert extra["top_k"] == 42
+
+ def test_value_none_skips(self):
+ extra = {}
+ _set_extra(extra, "top_k", None)
+ assert "top_k" not in extra
+
+ def test_value_equals_ignore_value_skips(self):
+ extra = {}
+ _set_extra(extra, "top_k", -1, ignore_value=-1)
+ assert "top_k" not in extra
+
+ def test_value_set_when_valid(self):
+ extra = {}
+ _set_extra(extra, "top_k", 10, ignore_value=-1)
+ assert extra["top_k"] == 10
+
+
+class TestBuildRequestBody:
+ def test_max_tokens_capped_by_nim(self, req):
+ req.max_tokens = 100000
+ nim = NimSettings(max_tokens=4096)
+ body = build_request_body(req, nim)
+ assert body["max_tokens"] == 4096
+
+ def test_presence_penalty_included_when_nonzero(self, req):
+ nim = NimSettings(presence_penalty=0.5)
+ body = build_request_body(req, nim)
+ assert body["presence_penalty"] == 0.5
+
+ def test_include_stop_str_in_output_not_sent(self, req):
+ body = build_request_body(req, NimSettings())
+ assert "include_stop_str_in_output" not in body.get("extra_body", {})
+
+ def test_parallel_tool_calls_included(self, req):
+ nim = NimSettings(parallel_tool_calls=False)
+ body = build_request_body(req, nim)
+ assert body["parallel_tool_calls"] is False
+
+ def test_reasoning_params_in_extra_body(self):
+ req = MagicMock()
+ req.model = "test"
+ req.messages = [MagicMock(role="user", content="hi")]
+ req.max_tokens = 100
+ req.system = None
+ req.temperature = None
+ req.top_p = None
+ req.stop_sequences = None
+ req.tools = None
+ req.tool_choice = None
+ req.extra_body = None
+ req.top_k = None
+
+ nim = NimSettings(enable_thinking=True)
+ body = build_request_body(req, nim)
+ extra = body["extra_body"]
+ assert extra["chat_template_kwargs"] == {
+ "thinking": True,
+ "enable_thinking": True,
+ }
+ assert extra["reasoning_budget"] == body["max_tokens"]
+
+ def test_no_chat_template_kwargs_when_thinking_disabled(self):
+ req = MagicMock()
+ req.model = "test"
+ req.messages = [MagicMock(role="user", content="hi")]
+ req.max_tokens = 100
+ req.system = None
+ req.temperature = None
+ req.top_p = None
+ req.stop_sequences = None
+ req.tools = None
+ req.tool_choice = None
+ req.extra_body = None
+ req.top_k = None
+
+ nim = NimSettings(enable_thinking=False)
+ body = build_request_body(req, nim)
+ extra = body.get("extra_body", {})
+ assert "chat_template_kwargs" not in extra
+ assert "reasoning_budget" not in extra
+
+ def test_no_reasoning_params_in_extra_body(self):
+ req = MagicMock()
+ req.model = "test"
+ req.messages = [MagicMock(role="user", content="hi")]
+ req.max_tokens = 100
+ req.system = None
+ req.temperature = None
+ req.top_p = None
+ req.stop_sequences = None
+ req.tools = None
+ req.tool_choice = None
+ req.extra_body = None
+ req.top_k = None
+
+ nim = NimSettings()
+ body = build_request_body(req, nim)
+ extra = body.get("extra_body", {})
+ for param in (
+ "thinking",
+ "reasoning_split",
+ "return_tokens_as_token_ids",
+ "include_reasoning",
+ "reasoning_effort",
+ ):
+ assert param not in extra
diff --git a/Claude_Code/tests/providers/test_open_router.py b/Claude_Code/tests/providers/test_open_router.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f801a7ea16a91e9fe1986fe542883bd39b190b6
--- /dev/null
+++ b/Claude_Code/tests/providers/test_open_router.py
@@ -0,0 +1,344 @@
+"""Tests for OpenRouter provider."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from providers.base import ProviderConfig
+from providers.open_router import OpenRouterProvider
+from providers.open_router.request import OPENROUTER_DEFAULT_MAX_TOKENS
+
+
+class MockMessage:
+ def __init__(self, role, content):
+ self.role = role
+ self.content = content
+
+
+class MockRequest:
+ def __init__(self, **kwargs):
+ self.model = "stepfun/step-3.5-flash:free"
+ self.messages = [MockMessage("user", "Hello")]
+ self.max_tokens = 100
+ self.temperature = 0.5
+ self.top_p = 0.9
+ self.system = "System prompt"
+ self.stop_sequences = None
+ self.tools = []
+ self.extra_body = {}
+ self.thinking = MagicMock()
+ self.thinking.enabled = True
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+
+@pytest.fixture
+def open_router_config():
+ return ProviderConfig(
+ api_key="test_openrouter_key",
+ base_url="https://openrouter.ai/api/v1",
+ rate_limit=10,
+ rate_window=60,
+ )
+
+
+@pytest.fixture(autouse=True)
+def mock_rate_limiter():
+ """Mock the global rate limiter to prevent waiting."""
+ with patch("providers.openai_compat.GlobalRateLimiter") as mock:
+ instance = mock.get_instance.return_value
+ instance.wait_if_blocked = AsyncMock(return_value=False)
+
+ async def _passthrough(fn, *args, **kwargs):
+ return await fn(*args, **kwargs)
+
+ instance.execute_with_retry = AsyncMock(side_effect=_passthrough)
+ yield instance
+
+
+@pytest.fixture
+def open_router_provider(open_router_config):
+ return OpenRouterProvider(open_router_config)
+
+
+def test_init(open_router_config):
+ """Test provider initialization."""
+ with patch("providers.openai_compat.AsyncOpenAI") as mock_openai:
+ provider = OpenRouterProvider(open_router_config)
+ assert provider._api_key == "test_openrouter_key"
+ assert provider._base_url == "https://openrouter.ai/api/v1"
+ mock_openai.assert_called_once()
+
+
+def test_init_uses_configurable_timeouts():
+ """Test that provider passes configurable read/write/connect timeouts to client."""
+ config = ProviderConfig(
+ api_key="test_openrouter_key",
+ base_url="https://openrouter.ai/api/v1",
+ http_read_timeout=600.0,
+ http_write_timeout=15.0,
+ http_connect_timeout=5.0,
+ )
+ with patch("providers.openai_compat.AsyncOpenAI") as mock_openai:
+ OpenRouterProvider(config)
+ call_kwargs = mock_openai.call_args[1]
+ timeout = call_kwargs["timeout"]
+ assert timeout.read == 600.0
+ assert timeout.write == 15.0
+ assert timeout.connect == 5.0
+
+
+def test_build_request_body_has_reasoning_extra(open_router_provider):
+ """Request body has extra_body.reasoning.enabled for thinking models."""
+ req = MockRequest()
+ body = open_router_provider._build_request_body(req)
+
+ assert body["model"] == "stepfun/step-3.5-flash:free"
+ assert body["temperature"] == 0.5
+ assert len(body["messages"]) == 2 # System + User
+ assert body["messages"][0]["role"] == "system"
+ assert body["messages"][0]["content"] == "System prompt"
+
+ assert "extra_body" in body
+ assert "reasoning" in body["extra_body"]
+ assert body["extra_body"]["reasoning"]["enabled"] is True
+
+
+def test_build_request_body_base_url_and_model(open_router_provider):
+ """Base URL and model are correct in provider config."""
+ assert open_router_provider._base_url == "https://openrouter.ai/api/v1"
+ req = MockRequest(model="stepfun/step-3.5-flash:free")
+ body = open_router_provider._build_request_body(req)
+ assert body["model"] == "stepfun/step-3.5-flash:free"
+
+
+def test_build_request_body_default_max_tokens(open_router_provider):
+ """max_tokens=None uses OPENROUTER_DEFAULT_MAX_TOKENS (81920)."""
+ req = MockRequest(max_tokens=None)
+ body = open_router_provider._build_request_body(req)
+ assert body["max_tokens"] == OPENROUTER_DEFAULT_MAX_TOKENS
+ assert body["max_tokens"] == 81920
+
+
+@pytest.mark.asyncio
+async def test_stream_response_text(open_router_provider):
+ """Test streaming text response."""
+ req = MockRequest()
+
+ mock_chunk1 = MagicMock()
+ mock_chunk1.choices = [
+ MagicMock(
+ delta=MagicMock(content="Hello", reasoning_content=None),
+ finish_reason=None,
+ )
+ ]
+ mock_chunk1.usage = None
+
+ mock_chunk2 = MagicMock()
+ mock_chunk2.choices = [
+ MagicMock(
+ delta=MagicMock(content=" World", reasoning_content=None),
+ finish_reason="stop",
+ )
+ ]
+ mock_chunk2.usage = MagicMock(completion_tokens=10)
+
+ async def mock_stream():
+ yield mock_chunk1
+ yield mock_chunk2
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+
+ events = [e async for e in open_router_provider.stream_response(req)]
+
+ assert len(events) > 0
+ assert "event: message_start" in events[0]
+
+ text_content = ""
+ for e in events:
+ if "event: content_block_delta" in e and '"text_delta"' in e:
+ for line in e.splitlines():
+ if line.startswith("data: "):
+ data = json.loads(line[6:])
+ if "delta" in data and "text" in data["delta"]:
+ text_content += data["delta"]["text"]
+
+ assert "Hello World" in text_content
+
+
+@pytest.mark.asyncio
+async def test_stream_response_reasoning_content(open_router_provider):
+ """Test streaming with reasoning_content delta."""
+ req = MockRequest()
+
+ mock_chunk = MagicMock()
+ mock_chunk.choices = [
+ MagicMock(
+ delta=MagicMock(content=None, reasoning_content="Thinking..."),
+ finish_reason=None,
+ )
+ ]
+ mock_chunk.usage = None
+
+ async def mock_stream():
+ yield mock_chunk
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+
+ events = [e async for e in open_router_provider.stream_response(req)]
+
+ found_thinking = False
+ for e in events:
+ if (
+ "event: content_block_delta" in e
+ and '"thinking_delta"' in e
+ and "Thinking..." in e
+ ):
+ found_thinking = True
+ assert found_thinking
+
+
+@pytest.mark.asyncio
+async def test_stream_response_empty_choices_skipped(open_router_provider):
+ """Chunks with empty choices are skipped."""
+ req = MockRequest()
+
+ async def mock_stream():
+ yield MagicMock(choices=[], usage=None)
+ yield MagicMock(
+ choices=[
+ MagicMock(
+ delta=MagicMock(content="ok", reasoning_content=None),
+ finish_reason="stop",
+ )
+ ],
+ usage=MagicMock(completion_tokens=2),
+ )
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+ events = [e async for e in open_router_provider.stream_response(req)]
+ assert any("content_block_delta" in e and "ok" in e for e in events)
+
+
+@pytest.mark.asyncio
+async def test_stream_response_delta_none_skipped(open_router_provider):
+ """Chunks with delta=None are skipped."""
+ req = MockRequest()
+
+ async def mock_stream():
+ yield MagicMock(
+ choices=[MagicMock(delta=None, finish_reason=None)],
+ usage=None,
+ )
+ yield MagicMock(
+ choices=[
+ MagicMock(
+ delta=MagicMock(content="x", reasoning_content=None),
+ finish_reason="stop",
+ )
+ ],
+ usage=MagicMock(completion_tokens=1),
+ )
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+ events = [e async for e in open_router_provider.stream_response(req)]
+ assert any("x" in e for e in events)
+
+
+@pytest.mark.asyncio
+async def test_stream_response_reasoning_details(open_router_provider):
+ """Streaming with reasoning_details (stepfun format)."""
+ req = MockRequest()
+
+ mock_chunk = MagicMock()
+ mock_chunk.choices = [
+ MagicMock(
+ delta=MagicMock(
+ content=None,
+ reasoning_content=None,
+ reasoning_details=[{"text": "Step 1"}],
+ ),
+ finish_reason=None,
+ )
+ ]
+ mock_chunk.usage = None
+
+ async def mock_stream():
+ yield mock_chunk
+ yield MagicMock(
+ choices=[
+ MagicMock(
+ delta=MagicMock(
+ content=None,
+ reasoning_content=None,
+ reasoning_details=None,
+ ),
+ finish_reason="stop",
+ )
+ ],
+ usage=MagicMock(completion_tokens=5),
+ )
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+ events = [e async for e in open_router_provider.stream_response(req)]
+ assert any("Step 1" in e for e in events)
+
+
+@pytest.mark.asyncio
+async def test_stream_response_error_path(open_router_provider):
+ """Stream raises exception -> error event emitted."""
+ req = MockRequest()
+
+ async def mock_stream():
+ raise RuntimeError("API failed")
+ yield # unreachable, makes it a generator
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+ events = [e async for e in open_router_provider.stream_response(req)]
+ # Error is emitted; message_stop/done indicates stream completed
+ assert any("API failed" in e for e in events)
+ assert any("message_stop" in e for e in events)
+
+
+@pytest.mark.asyncio
+async def test_stream_response_finish_reason_only(open_router_provider):
+ """Chunk with finish_reason but no content still completes."""
+ req = MockRequest()
+
+ async def mock_stream():
+ yield MagicMock(
+ choices=[
+ MagicMock(
+ delta=MagicMock(content=None, reasoning_content=None),
+ finish_reason="stop",
+ )
+ ],
+ usage=MagicMock(completion_tokens=0),
+ )
+
+ with patch.object(
+ open_router_provider._client.chat.completions, "create", new_callable=AsyncMock
+ ) as mock_create:
+ mock_create.return_value = mock_stream()
+ events = [e async for e in open_router_provider.stream_response(req)]
+ assert any("message_delta" in e for e in events)
+ assert any("message_stop" in e for e in events)
diff --git a/Claude_Code/tests/providers/test_parsers.py b/Claude_Code/tests/providers/test_parsers.py
new file mode 100644
index 0000000000000000000000000000000000000000..e84edaa236df1eda7c935a784eeb16ad02f47ba5
--- /dev/null
+++ b/Claude_Code/tests/providers/test_parsers.py
@@ -0,0 +1,476 @@
+import pytest
+
+from providers.common import ContentType, HeuristicToolParser, ThinkTagParser
+
+
+def test_think_tag_parser_basic():
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("Hello reasoning world"))
+
+ assert len(chunks) == 3
+ assert chunks[0].type == ContentType.TEXT
+ assert chunks[0].content == "Hello "
+ assert chunks[1].type == ContentType.THINKING
+ assert chunks[1].content == "reasoning"
+ assert chunks[2].type == ContentType.TEXT
+ assert chunks[2].content == " world"
+
+
+def test_think_tag_parser_streaming():
+ parser = ThinkTagParser()
+
+ # Partial tag
+ chunks = list(parser.feed("Hello reasoning"))
+ assert len(chunks) == 1
+ assert chunks[0].type == ContentType.THINKING
+ assert chunks[0].content == "reasoning"
+
+
+def test_heuristic_tool_parser_basic():
+ parser = HeuristicToolParser()
+ text = "Let's call a tool. ● hello."
+ filtered, tools_initial = parser.feed(text)
+ tools_final = parser.flush()
+ tools = tools_initial + tools_final
+
+ assert "Let's call a tool." in filtered
+ assert len(tools) == 1
+ assert tools[0]["name"] == "Grep"
+ assert tools[0]["input"] == {"pattern": "hello", "path": "."}
+
+
+def test_heuristic_tool_parser_streaming():
+ parser = HeuristicToolParser()
+
+ # Feed part 1
+ _filtered1, tools1 = parser.feed("● ")
+ assert tools1 == []
+
+ # Feed part 2
+ _filtered2, tools2 = parser.feed("test.txt")
+ assert tools2 == []
+
+ # Feed part 3 (triggering flush or completion)
+ filtered3, tools3 = parser.feed("\nDone.")
+ assert len(tools3) == 1
+ assert tools3[0]["name"] == "Write"
+ assert tools3[0]["input"] == {"path": "test.txt"}
+ assert "Done." in filtered3
+
+
+def test_heuristic_tool_parser_flush():
+ parser = HeuristicToolParser()
+ parser.feed("● ls -la")
+ tools = parser.flush()
+
+ assert len(tools) == 1
+ assert tools[0]["name"] == "Bash"
+ assert tools[0]["input"] == {"command": "ls -la"}
+
+
+def test_heuristic_tool_parser_strips_control_tokens():
+ p = HeuristicToolParser()
+ filtered, tools = p.feed("Hello <|tool_call_end|> world")
+ tools.extend(p.flush())
+
+ assert "<|tool_call_end|>" not in filtered
+ assert filtered == "Hello world"
+ assert tools == []
+
+
+def test_heuristic_tool_parser_strips_control_tokens_split_across_chunks():
+ p = HeuristicToolParser()
+ f1, t1 = p.feed("Hello <|tool_call_")
+ f2, t2 = p.feed("end|> world")
+ tools = t1 + t2 + p.flush()
+
+ assert "<|tool_call_end|>" not in (f1 + f2)
+ assert (f1 + f2) == "Hello world"
+ assert tools == []
+
+
+def test_heuristic_tool_parser_strips_control_tokens_inside_tool_text():
+ p = HeuristicToolParser()
+ text = (
+ "Before <|tool_calls_section_end|> ● "
+ "hi After"
+ )
+ filtered, tools = p.feed(text)
+ tools.extend(p.flush())
+
+ assert "<|tool_calls_section_end|>" not in filtered
+ assert "Before" in filtered
+ assert "After" in filtered
+ assert len(tools) == 1
+ assert tools[0]["name"] == "Grep"
+ assert tools[0]["input"] == {"pattern": "hi"}
+
+
+def test_interleaved_thinking_and_tools():
+ parser_think = ThinkTagParser()
+ parser_tool = HeuristicToolParser()
+
+ text = "I need to search for a file. ● test"
+
+ # 1. Parse thinking
+ chunks = list(parser_think.feed(text))
+ thinking = [c for c in chunks if c.type == ContentType.THINKING]
+ text_remaining = "".join([c.content for c in chunks if c.type == ContentType.TEXT])
+
+ assert len(thinking) == 1
+ assert thinking[0].content == "I need to search for a file."
+
+ # 2. Parse tool from remaining text
+ _filtered, tools = parser_tool.feed(text_remaining)
+ tools += parser_tool.flush()
+
+ assert len(tools) == 1
+ assert tools[0]["name"] == "Grep"
+ assert tools[0]["input"] == {"pattern": "test"}
+
+
+def test_partial_interleaved_streaming():
+ parser_think = ThinkTagParser()
+ parser_tool = HeuristicToolParser()
+
+ # Chunk 1: Partial thinking (it emits since it's definitely not the start of )
+ chunks1 = list(parser_think.feed("Part 1"))
+ assert len(chunks1) == 1
+ assert chunks1[0].type == ContentType.THINKING
+ assert chunks1[0].content == "Part 1"
+
+ # Chunk 2: Thinking ends, tool starts
+ chunks2 = list(parser_think.feed(" ends ● test.py"))
+ text_rem3 = "".join([c.content for c in chunks3])
+ _filtered3, tools3 = parser_tool.feed(text_rem3)
+ tools3 += parser_tool.flush()
+
+ assert len(tools3) == 1
+ assert tools3[0]["name"] == "Read"
+ assert tools3[0]["input"] == {"path": "test.py"}
+
+
+# --- New Robustness Tests ---
+
+
+def test_split_across_markers():
+ # Split across the trigger chaaracter
+ # "● "
+ # Split at various points
+ full_text = "● val"
+
+ for i in range(len(full_text)):
+ p = HeuristicToolParser()
+ chunk1 = full_text[:i]
+ chunk2 = full_text[i:]
+
+ tools = []
+ _filtered, t = p.feed(chunk1)
+ tools.extend(t)
+ _filtered2, t = p.feed(chunk2)
+ tools.extend(t)
+ tools.extend(p.flush())
+
+ if len(tools) != 1:
+ print(f"Failed split at index {i}: '{chunk1}' | '{chunk2}'")
+
+ assert len(tools) == 1, f"Failed split at index {i}"
+ assert tools[0]["name"] == "Test"
+ assert tools[0]["input"] == {"arg": "val"}
+
+
+def test_value_with_special_chars():
+ parser = HeuristicToolParser()
+ # Value with > inside
+ text = "● a > b"
+ _, tools = parser.feed(text)
+ tools.extend(parser.flush())
+
+ assert len(tools) == 1
+ assert tools[0]["input"]["arg"] == "a > b"
+
+
+def test_multiple_params_split():
+ full_text = (
+ "● v1v2"
+ )
+
+ for i in range(len(full_text)):
+ p = HeuristicToolParser()
+ tools = []
+ _, t = p.feed(full_text[:i])
+ tools.extend(t)
+ _, t = p.feed(full_text[i:])
+ tools.extend(t)
+ tools.extend(p.flush())
+
+ assert len(tools) == 1, f"Failed split at {i}"
+ assert tools[0]["input"] == {"p1": "v1", "p2": "v2"}
+
+
+def test_incomplete_tag_flush():
+ p = HeuristicToolParser()
+ p.feed("● hello")
+ tools = p.flush()
+
+ assert len(tools) == 1
+ assert tools[0]["input"]["msg"] == "hello"
+
+
+def test_garbage_interleaved():
+ p = HeuristicToolParser()
+ tools = []
+ _, t = p.feed("Some text ")
+ tools.extend(t)
+ _, t = p.feed("● 1")
+ tools.extend(t)
+ _, t = p.feed(" more text ")
+ tools.extend(t)
+ _, t = p.feed("● 2")
+ tools.extend(t)
+ tools.extend(p.flush())
+
+ assert len(tools) == 2
+ assert tools[0]["name"] == "T1"
+ assert tools[1]["name"] == "T2"
+
+
+def test_text_between_params_lost():
+ p = HeuristicToolParser()
+ # " text1 " is between function end and first param
+ # " text2 " is between params
+ text = "● text1 1 text2 2"
+ filtered, tools = p.feed(text)
+ tools.extend(p.flush())
+
+ # Check if "text1" and "text2" are preserved in filtered output
+ assert "text1" in filtered
+ assert "text2" in filtered
+ assert tools[0]["input"] == {"a": "1", "b": "2"}
+
+
+# --- Orphan Tag Tests (Step Fun AI compatibility) ---
+
+
+def test_orphan_close_tag_stripped():
+ """Orphan without opening tag should be stripped."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("Hello world"))
+
+ # Should get one text chunk with orphan tag stripped
+ assert len(chunks) == 2
+ assert chunks[0].type == ContentType.TEXT
+ assert chunks[0].content == "Hello "
+ assert chunks[1].type == ContentType.TEXT
+ assert chunks[1].content == " world"
+
+
+def test_orphan_close_tag_at_start():
+ """Orphan at start should be stripped."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("Hello world"))
+
+ assert len(chunks) == 1
+ assert chunks[0].type == ContentType.TEXT
+ assert chunks[0].content == "Hello world"
+
+
+def test_orphan_close_tag_at_end():
+ """Orphan at end should be stripped."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("Hello world"))
+
+ assert len(chunks) == 1
+ assert chunks[0].type == ContentType.TEXT
+ assert chunks[0].content == "Hello world"
+
+
+def test_multiple_orphan_close_tags():
+ """Multiple orphan tags should all be stripped."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("abc"))
+
+ text = "".join(c.content for c in chunks if c.type == ContentType.TEXT)
+ assert text == "abc"
+ assert "" not in text
+
+
+def test_orphan_close_tag_streaming():
+ """Orphan split across chunks should be stripped."""
+ parser = ThinkTagParser()
+
+ # Feed partial orphan tag
+ chunks1 = list(parser.feed("Hello world"))
+ assert len(chunks2) == 1
+ assert chunks2[0].type == ContentType.TEXT
+ assert chunks2[0].content == " world"
+
+
+def test_orphan_close_with_valid_think_pair():
+ """Orphan followed by valid ... pair."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("abthinkingc"))
+
+ types = [c.type for c in chunks]
+ # contents = [c.content for c in chunks] # Unused
+
+ assert ContentType.TEXT in types
+ assert ContentType.THINKING in types
+ # Text should be "ab" and "c", thinking should be "thinking"
+ text_content = "".join(c.content for c in chunks if c.type == ContentType.TEXT)
+ think_content = "".join(c.content for c in chunks if c.type == ContentType.THINKING)
+ assert text_content == "abc"
+ assert think_content == "thinking"
+
+
+# --- Parametrized Edge Case Tests ---
+
+
+@pytest.mark.parametrize(
+ "input_text,expected_text",
+ [
+ ("Hello world", "Hello world"),
+ ("Hello world", "Hello world"),
+ ("Hello world", "Hello world"),
+ ("abc", "abc"),
+ ("", ""),
+ ("", ""),
+ ],
+ ids=[
+ "middle",
+ "start",
+ "end",
+ "multiple",
+ "only_orphan",
+ "consecutive_orphans",
+ ],
+)
+def test_orphan_close_tag_parametrized(input_text, expected_text):
+ """Parametrized: orphan tags should be stripped from various positions."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed(input_text))
+ text = "".join(c.content for c in chunks if c.type == ContentType.TEXT)
+ assert text == expected_text
+ assert "" not in text
+
+
+def test_think_tag_parser_empty_input():
+ """Empty string input should yield no chunks."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed(""))
+ assert chunks == []
+
+
+def test_think_tag_parser_flush_no_content():
+ """Flush with no buffered content should return None."""
+ parser = ThinkTagParser()
+ result = parser.flush()
+ assert result is None
+
+
+def test_think_tag_parser_flush_buffered_text():
+ """Flush with buffered text returns TEXT chunk."""
+ parser = ThinkTagParser()
+ # Feed partial tag that stays buffered
+ list(parser.feed("Hello with buffered partial close tag returns THINKING chunk."""
+ parser = ThinkTagParser()
+ # Feed content that ends with a potential partial tag, which stays buffered
+ chunks = list(parser.feed("partial reasoning pair should yield no thinking content."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("remaining"))
+ # Empty think yields nothing for thinking, just the remaining text
+ # types = [c.type for c in chunks] # Unused
+ text = "".join(c.content for c in chunks if c.type == ContentType.TEXT)
+ assert text == "remaining"
+
+
+def test_think_tag_parser_unicode():
+ """Unicode content inside and outside think tags."""
+ parser = ThinkTagParser()
+ chunks = list(parser.feed("日本語 思考中 🤔 結果"))
+ thinking = "".join(c.content for c in chunks if c.type == ContentType.THINKING)
+ text = "".join(c.content for c in chunks if c.type == ContentType.TEXT)
+ assert thinking == "思考中 🤔"
+ assert "日本語" in text
+ assert "結果" in text
+
+
+def test_heuristic_tool_parser_empty_input():
+ """Empty string input should return empty filtered text and no tools."""
+ parser = HeuristicToolParser()
+ filtered, tools = parser.feed("")
+ assert filtered == ""
+ assert tools == []
+
+
+def test_heuristic_tool_parser_flush_no_tool():
+ """Flush when no tool is being parsed should return empty list."""
+ parser = HeuristicToolParser()
+ parser.feed("plain text")
+ tools = parser.flush()
+ assert tools == []
+
+
+def test_heuristic_tool_parser_unicode_function_name():
+ """Unicode characters in function parameters."""
+ parser = HeuristicToolParser()
+ text = "● 日本語テスト"
+ _filtered, tools = parser.feed(text)
+ tools.extend(parser.flush())
+ assert len(tools) == 1
+ assert tools[0]["name"] == "Search"
+ assert tools[0]["input"]["query"] == "日本語テスト"
+
+
+@pytest.mark.parametrize(
+ "malformed_text",
+ [
+ "● ",
+ "● v",
+ ],
+ ids=["empty_name", "empty_name_with_param"],
+)
+def test_heuristic_tool_parser_malformed_function_tag(malformed_text):
+ """Malformed function tags should still be handled without crashing."""
+ parser = HeuristicToolParser()
+ _filtered, tools = parser.feed(malformed_text)
+ tools.extend(parser.flush())
+ # Should not crash; may or may not detect a tool depending on regex match
diff --git a/Claude_Code/tests/providers/test_provider_rate_limit.py b/Claude_Code/tests/providers/test_provider_rate_limit.py
new file mode 100644
index 0000000000000000000000000000000000000000..c8d2061fa5c0b571aef682f2d738d7f99af10c88
--- /dev/null
+++ b/Claude_Code/tests/providers/test_provider_rate_limit.py
@@ -0,0 +1,317 @@
+import asyncio
+import time
+
+import pytest
+import pytest_asyncio
+
+from providers.rate_limit import GlobalRateLimiter
+
+
+class TestProviderRateLimiter:
+ """Tests for providers.rate_limit.GlobalRateLimiter."""
+
+ @pytest_asyncio.fixture(autouse=True)
+ async def reset_limiter(self):
+ """Reset singleton before each test."""
+ GlobalRateLimiter.reset_instance()
+ yield
+ GlobalRateLimiter.reset_instance()
+
+ @pytest.mark.asyncio
+ async def test_proactive_throttling(self):
+ """
+ Test proactive throttling.
+ Logic ported from verify_provider_limiter.py
+ """
+ # Re-init with tight limits: 1 request per 0.25 second
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance(rate_limit=1, rate_window=0.25)
+
+ start_time = time.time()
+
+ async def call_limiter():
+ await limiter.wait_if_blocked()
+ return time.time()
+
+ # 5 requests.
+ # R0 -> 0s
+ # R1 -> 0.25s
+ # R2 -> 0.50s
+ # R3 -> 0.75s
+ # R4 -> 1.00s
+ results = [await call_limiter() for _ in range(5)]
+
+ total_time = time.time() - start_time
+
+ assert len(results) == 5
+ # Should take at least ~1.0s
+ assert total_time >= 0.9, f"Throttling failed, took too fast: {total_time:.2f}s"
+
+ @pytest.mark.asyncio
+ async def test_reactive_blocking(self):
+ """
+ Test reactive blocking when set_blocked is called.
+ Logic ported from verify_provider_limiter.py
+ """
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance()
+
+ start_time = time.time()
+
+ # Manually block for 1.5s
+ block_time = 1.5
+ limiter.set_blocked(block_time)
+
+ assert limiter.is_blocked()
+
+ async def call_limiter():
+ return await limiter.wait_if_blocked()
+
+ # Run 2 calls concurrently
+ # They should both wait for the block time
+ results = await asyncio.gather(call_limiter(), call_limiter())
+
+ total_time = time.time() - start_time
+
+ # Both should report having waited reactively
+ assert all(results) is True
+ assert total_time >= block_time - 0.1, (
+ f"Reactive block failed, took {total_time:.2f}s"
+ )
+
+ @pytest.mark.asyncio
+ async def test_set_blocked_zero_immediately_unblocks(self):
+ """set_blocked(0) should not actually block."""
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+ limiter.set_blocked(0)
+
+ # Should not be blocked since 0 seconds from now is already past
+ await asyncio.sleep(0.01)
+ assert limiter.is_blocked() is False
+ assert limiter.remaining_wait() == 0
+
+ @pytest.mark.asyncio
+ async def test_remaining_wait_when_not_blocked(self):
+ """remaining_wait() should return 0 when not blocked."""
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+ assert limiter.remaining_wait() == 0
+
+ @pytest.mark.asyncio
+ async def test_remaining_wait_decreases(self):
+ """remaining_wait() should decrease over time."""
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+ limiter.set_blocked(2.0)
+
+ wait1 = limiter.remaining_wait()
+ assert wait1 > 1.5
+
+ await asyncio.sleep(0.5)
+ wait2 = limiter.remaining_wait()
+ assert wait2 < wait1
+
+ @pytest.mark.asyncio
+ async def test_is_blocked_false_initially(self):
+ """is_blocked() should be False for a fresh limiter."""
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+ assert limiter.is_blocked() is False
+
+ @pytest.mark.asyncio
+ async def test_high_rate_limit_no_throttling(self):
+ """Very high rate limit should not cause throttling."""
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance(rate_limit=10000, rate_window=60)
+
+ start = time.time()
+ for _ in range(20):
+ await limiter.wait_if_blocked()
+ duration = time.time() - start
+
+ # 20 requests with 10000 limit should be near-instant
+ assert duration < 1.0, f"High rate limit caused throttling: {duration:.2f}s"
+
+ @pytest.mark.asyncio
+ async def test_singleton_pattern(self):
+ """get_instance should return the same object."""
+ limiter1 = GlobalRateLimiter.get_instance(rate_limit=10, rate_window=1)
+ limiter2 = GlobalRateLimiter.get_instance()
+ assert limiter1 is limiter2
+
+ @pytest.mark.asyncio
+ async def test_reset_instance(self):
+ """reset_instance should allow creating a new instance."""
+ limiter1 = GlobalRateLimiter.get_instance(rate_limit=10, rate_window=1)
+ GlobalRateLimiter.reset_instance()
+ limiter2 = GlobalRateLimiter.get_instance(rate_limit=20, rate_window=2)
+ assert limiter1 is not limiter2
+
+ @pytest.mark.asyncio
+ async def test_wait_if_blocked_returns_false_when_not_blocked(self):
+ """wait_if_blocked should return False when not reactively blocked."""
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+ result = await limiter.wait_if_blocked()
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_proactive_strict_rolling_window(self):
+ """
+ Proactive limiter should enforce a strict rolling window:
+ for any i, t[i+rate_limit] - t[i] >= rate_window (within tolerance).
+ """
+ GlobalRateLimiter.reset_instance()
+ rate_limit = 2
+ rate_window = 0.5
+ limiter = GlobalRateLimiter.get_instance(
+ rate_limit=rate_limit, rate_window=rate_window
+ )
+
+ acquired: list[float] = []
+
+ async def acquire():
+ await limiter.wait_if_blocked()
+ acquired.append(time.monotonic())
+
+ # Trigger concurrency; without strict rolling windows, this can burst.
+ await asyncio.gather(*(acquire() for _ in range(5)))
+
+ acquired.sort()
+ assert len(acquired) == 5
+
+ tolerance = 0.05
+ for i in range(len(acquired) - rate_limit):
+ assert acquired[i + rate_limit] - acquired[i] >= rate_window - tolerance, (
+ f"Rolling window violated at i={i}: "
+ f"dt={acquired[i + rate_limit] - acquired[i]:.3f}s"
+ )
+
+ @pytest.mark.asyncio
+ async def test_init_rate_limit_zero_raises(self):
+ """rate_limit <= 0 raises ValueError."""
+ GlobalRateLimiter.reset_instance()
+ with pytest.raises(ValueError, match="rate_limit must be > 0"):
+ GlobalRateLimiter(rate_limit=0, rate_window=60)
+
+ @pytest.mark.asyncio
+ async def test_init_rate_window_zero_raises(self):
+ """rate_window <= 0 raises ValueError."""
+ GlobalRateLimiter.reset_instance()
+ with pytest.raises(ValueError, match="rate_window must be > 0"):
+ GlobalRateLimiter(rate_limit=10, rate_window=0)
+
+ @pytest.mark.asyncio
+ async def test_execute_with_retry_exhaust_retries_raises(self):
+ """When all 429 retries exhausted, last exception is raised."""
+ import openai
+ from httpx import Request, Response
+
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+
+ def make_429():
+ return openai.RateLimitError(
+ "rate limited",
+ response=Response(429, request=Request("POST", "http://x")),
+ body={},
+ )
+
+ async def fail():
+ raise make_429()
+
+ with pytest.raises(openai.RateLimitError):
+ await limiter.execute_with_retry(
+ fail, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0
+ )
+
+ @pytest.mark.asyncio
+ async def test_execute_with_retry_succeeds_on_retry(self):
+ """429 then success returns result."""
+ import openai
+ from httpx import Request, Response
+
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance(rate_limit=100, rate_window=60)
+
+ def make_429():
+ return openai.RateLimitError(
+ "rate limited",
+ response=Response(429, request=Request("POST", "http://x")),
+ body={},
+ )
+
+ call_count = 0
+
+ async def fail_then_ok():
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise make_429()
+ return "ok"
+
+ result = await limiter.execute_with_retry(
+ fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0
+ )
+ assert result == "ok"
+ assert call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_max_concurrency_zero_raises(self):
+ """max_concurrency <= 0 raises ValueError."""
+ GlobalRateLimiter.reset_instance()
+ with pytest.raises(ValueError, match="max_concurrency must be > 0"):
+ GlobalRateLimiter(rate_limit=10, rate_window=60, max_concurrency=0)
+
+ @pytest.mark.asyncio
+ async def test_concurrency_slot_limits_simultaneous_streams(self):
+ """At most max_concurrency streams can hold a slot simultaneously."""
+ GlobalRateLimiter.reset_instance()
+ max_concurrency = 2
+ limiter = GlobalRateLimiter.get_instance(
+ rate_limit=100, rate_window=60, max_concurrency=max_concurrency
+ )
+
+ peak_concurrent = 0
+ current_concurrent = 0
+ lock = asyncio.Lock()
+
+ async def stream_task(hold_time: float) -> None:
+ nonlocal peak_concurrent, current_concurrent
+ async with limiter.concurrency_slot():
+ async with lock:
+ current_concurrent += 1
+ if current_concurrent > peak_concurrent:
+ peak_concurrent = current_concurrent
+ await asyncio.sleep(hold_time)
+ async with lock:
+ current_concurrent -= 1
+
+ # Launch 5 tasks that each hold the slot; only 2 can be active at once
+ await asyncio.gather(*(stream_task(0.05) for _ in range(5)))
+
+ assert peak_concurrent <= max_concurrency, (
+ f"Concurrency exceeded: peak={peak_concurrent}, max={max_concurrency}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_concurrency_slot_releases_on_exception(self):
+ """Slot is released even when the body raises an exception."""
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance(
+ rate_limit=100, rate_window=60, max_concurrency=1
+ )
+ assert limiter._concurrency_sem is not None
+
+ with pytest.raises(RuntimeError):
+ async with limiter.concurrency_slot():
+ raise RuntimeError("boom")
+
+ # Semaphore value should be restored (1 available again)
+ assert limiter._concurrency_sem._value == 1
+
+ @pytest.mark.asyncio
+ async def test_get_instance_passes_max_concurrency(self):
+ """get_instance forwards max_concurrency to the singleton."""
+ GlobalRateLimiter.reset_instance()
+ limiter = GlobalRateLimiter.get_instance(
+ rate_limit=10, rate_window=60, max_concurrency=3
+ )
+ assert limiter._concurrency_sem is not None
+ assert limiter._concurrency_sem._value == 3
diff --git a/Claude_Code/tests/providers/test_sse_builder.py b/Claude_Code/tests/providers/test_sse_builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c2400fbcfd4a65b52801c00432d31f1b52ce1d0
--- /dev/null
+++ b/Claude_Code/tests/providers/test_sse_builder.py
@@ -0,0 +1,382 @@
+"""Tests for providers/nvidia_nim/utils/sse_builder.py."""
+
+import json
+from unittest.mock import patch
+
+import pytest
+
+from providers.common.sse_builder import (
+ ContentBlockManager,
+ SSEBuilder,
+ map_stop_reason,
+)
+
+
+def _parse_sse(sse_str: str) -> dict:
+ """Parse an SSE event string into its data payload."""
+ for line in sse_str.strip().split("\n"):
+ if line.startswith("data: "):
+ return json.loads(line[len("data: ") :])
+ raise ValueError(f"No data line found in SSE: {sse_str}")
+
+
+class TestMapStopReason:
+ """Tests for map_stop_reason function."""
+
+ @pytest.mark.parametrize(
+ "openai_reason,expected",
+ [
+ ("stop", "end_turn"),
+ ("length", "max_tokens"),
+ ("tool_calls", "tool_use"),
+ ("content_filter", "end_turn"),
+ (None, "end_turn"),
+ ("unknown_value", "end_turn"),
+ ("", "end_turn"),
+ ],
+ ids=[
+ "stop",
+ "length",
+ "tool_calls",
+ "content_filter",
+ "none",
+ "unknown",
+ "empty_string",
+ ],
+ )
+ def test_map_stop_reason(self, openai_reason, expected):
+ assert map_stop_reason(openai_reason) == expected
+
+
+class TestContentBlockManager:
+ """Tests for ContentBlockManager."""
+
+ def test_allocate_index_increments(self):
+ mgr = ContentBlockManager()
+ assert mgr.allocate_index() == 0
+ assert mgr.allocate_index() == 1
+ assert mgr.allocate_index() == 2
+
+ def test_initial_state(self):
+ mgr = ContentBlockManager()
+ assert mgr.thinking_index == -1
+ assert mgr.text_index == -1
+ assert mgr.thinking_started is False
+ assert mgr.text_started is False
+ assert mgr.tool_states == {}
+
+
+class TestSSEBuilderMessageLifecycle:
+ """Tests for message_start, message_delta, message_stop."""
+
+ def test_message_start(self):
+ builder = SSEBuilder("msg_123", "test-model", input_tokens=50)
+ sse = builder.message_start()
+
+ assert "event: message_start" in sse
+ data = _parse_sse(sse)
+ assert data["type"] == "message_start"
+ msg = data["message"]
+ assert msg["id"] == "msg_123"
+ assert msg["model"] == "test-model"
+ assert msg["role"] == "assistant"
+ assert msg["content"] == []
+ assert msg["usage"]["input_tokens"] == 50
+ assert msg["usage"]["output_tokens"] == 1
+
+ def test_message_delta(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.message_delta("end_turn", 42)
+
+ assert "event: message_delta" in sse
+ data = _parse_sse(sse)
+ assert data["type"] == "message_delta"
+ assert data["delta"]["stop_reason"] == "end_turn"
+ assert data["usage"]["output_tokens"] == 42
+
+ def test_message_stop(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.message_stop()
+
+ assert "event: message_stop" in sse
+ data = _parse_sse(sse)
+ assert data["type"] == "message_stop"
+
+
+class TestSSEBuilderContentBlocks:
+ """Tests for content block start/delta/stop events."""
+
+ def test_content_block_start_text(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_start(0, "text", text="hello")
+
+ data = _parse_sse(sse)
+ assert data["type"] == "content_block_start"
+ assert data["index"] == 0
+ assert data["content_block"]["type"] == "text"
+ assert data["content_block"]["text"] == "hello"
+
+ def test_content_block_start_thinking(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_start(1, "thinking")
+
+ data = _parse_sse(sse)
+ assert data["content_block"]["type"] == "thinking"
+ assert data["content_block"]["thinking"] == ""
+
+ def test_content_block_start_tool_use(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_start(
+ 2, "tool_use", id="tool_123", name="Read", input={}
+ )
+
+ data = _parse_sse(sse)
+ assert data["content_block"]["type"] == "tool_use"
+ assert data["content_block"]["id"] == "tool_123"
+ assert data["content_block"]["name"] == "Read"
+ assert data["content_block"]["input"] == {}
+
+ def test_content_block_delta_text(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_delta(0, "text_delta", "hello world")
+
+ data = _parse_sse(sse)
+ assert data["type"] == "content_block_delta"
+ assert data["index"] == 0
+ assert data["delta"]["type"] == "text_delta"
+ assert data["delta"]["text"] == "hello world"
+
+ def test_content_block_delta_thinking(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_delta(1, "thinking_delta", "reasoning...")
+
+ data = _parse_sse(sse)
+ assert data["delta"]["type"] == "thinking_delta"
+ assert data["delta"]["thinking"] == "reasoning..."
+
+ def test_content_block_delta_input_json(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_delta(2, "input_json_delta", '{"key": "val"}')
+
+ data = _parse_sse(sse)
+ assert data["delta"]["type"] == "input_json_delta"
+ assert data["delta"]["partial_json"] == '{"key": "val"}'
+
+ def test_content_block_stop(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.content_block_stop(0)
+
+ data = _parse_sse(sse)
+ assert data["type"] == "content_block_stop"
+ assert data["index"] == 0
+
+
+class TestSSEBuilderHighLevelHelpers:
+ """Tests for high-level thinking/text/tool block helpers."""
+
+ def test_start_and_stop_thinking_block(self):
+ builder = SSEBuilder("msg_1", "model")
+
+ start_sse = builder.start_thinking_block()
+ data = _parse_sse(start_sse)
+ assert data["content_block"]["type"] == "thinking"
+ assert builder.blocks.thinking_started is True
+ assert builder.blocks.thinking_index == 0
+
+ stop_sse = builder.stop_thinking_block()
+ data = _parse_sse(stop_sse)
+ assert data["type"] == "content_block_stop"
+ assert builder.blocks.thinking_started is False
+
+ def test_emit_thinking_delta_accumulates(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_thinking_block()
+
+ builder.emit_thinking_delta("part1 ")
+ builder.emit_thinking_delta("part2")
+
+ assert builder.accumulated_reasoning == "part1 part2"
+
+ def test_start_and_stop_text_block(self):
+ builder = SSEBuilder("msg_1", "model")
+
+ start_sse = builder.start_text_block()
+ data = _parse_sse(start_sse)
+ assert data["content_block"]["type"] == "text"
+ assert builder.blocks.text_started is True
+ assert builder.blocks.text_index == 0
+
+ builder.stop_text_block()
+ assert builder.blocks.text_started is False
+
+ def test_emit_text_delta_accumulates(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_text_block()
+
+ builder.emit_text_delta("hello ")
+ builder.emit_text_delta("world")
+
+ assert builder.accumulated_text == "hello world"
+
+ def test_start_tool_block(self):
+ builder = SSEBuilder("msg_1", "model")
+ sse = builder.start_tool_block(0, "tool_abc", "Grep")
+
+ data = _parse_sse(sse)
+ assert data["content_block"]["type"] == "tool_use"
+ assert data["content_block"]["id"] == "tool_abc"
+ assert data["content_block"]["name"] == "Grep"
+ assert 0 in builder.blocks.tool_states
+
+ def test_emit_tool_delta(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_tool_block(0, "tool_abc", "Grep")
+
+ sse = builder.emit_tool_delta(0, '{"pattern":')
+ data = _parse_sse(sse)
+ assert data["delta"]["partial_json"] == '{"pattern":'
+ assert "".join(builder.blocks.tool_states[0].contents) == '{"pattern":'
+
+ def test_stop_tool_block(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_tool_block(0, "tool_abc", "Grep")
+
+ sse = builder.stop_tool_block(0)
+ data = _parse_sse(sse)
+ assert data["type"] == "content_block_stop"
+
+
+class TestSSEBuilderStateManagement:
+ """Tests for ensure_thinking_block, ensure_text_block, close_all_blocks."""
+
+ def test_ensure_thinking_block_closes_text_first(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_text_block()
+ assert builder.blocks.text_started is True
+
+ events = list(builder.ensure_thinking_block())
+ # Should close text then start thinking
+ assert len(events) == 2
+ assert builder.blocks.text_started is False
+ assert builder.blocks.thinking_started is True
+
+ def test_ensure_thinking_block_noop_if_already_started(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_thinking_block()
+
+ events = list(builder.ensure_thinking_block())
+ assert events == []
+
+ def test_ensure_text_block_closes_thinking_first(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_thinking_block()
+ assert builder.blocks.thinking_started is True
+
+ events = list(builder.ensure_text_block())
+ # Should close thinking then start text
+ assert len(events) == 2
+ assert builder.blocks.thinking_started is False
+ assert builder.blocks.text_started is True
+
+ def test_ensure_text_block_noop_if_already_started(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_text_block()
+
+ events = list(builder.ensure_text_block())
+ assert events == []
+
+ def test_close_content_blocks(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_thinking_block()
+ builder.stop_thinking_block()
+ builder.start_text_block()
+
+ events = list(builder.close_content_blocks())
+ # Should close text (thinking already stopped)
+ assert len(events) == 1
+ assert builder.blocks.text_started is False
+
+ def test_close_all_blocks(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_thinking_block()
+ builder.stop_thinking_block()
+ builder.start_text_block()
+ builder.start_tool_block(0, "t1", "Read")
+ builder.start_tool_block(1, "t2", "Write")
+
+ events = list(builder.close_all_blocks())
+ # Close text + 2 tool blocks (thinking already stopped)
+ assert len(events) == 3
+ assert builder.blocks.text_started is False
+
+ def test_close_all_blocks_empty(self):
+ builder = SSEBuilder("msg_1", "model")
+ events = list(builder.close_all_blocks())
+ assert events == []
+
+
+class TestSSEBuilderError:
+ """Tests for emit_error."""
+
+ def test_emit_error(self):
+ builder = SSEBuilder("msg_1", "model")
+ events = list(builder.emit_error("Something went wrong"))
+
+ assert len(events) == 3 # start, delta, stop
+ start_data = _parse_sse(events[0])
+ assert start_data["content_block"]["type"] == "text"
+
+ delta_data = _parse_sse(events[1])
+ assert delta_data["delta"]["text"] == "Something went wrong"
+
+ stop_data = _parse_sse(events[2])
+ assert stop_data["type"] == "content_block_stop"
+
+
+class TestSSEBuilderTokenEstimation:
+ """Tests for estimate_output_tokens."""
+
+ def test_estimate_with_text_only(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_text_block()
+ builder.emit_text_delta("hello world")
+
+ tokens = builder.estimate_output_tokens()
+ assert tokens > 0
+
+ def test_estimate_with_reasoning(self):
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_thinking_block()
+ builder.emit_thinking_delta("deep thought")
+ builder.stop_thinking_block()
+ builder.start_text_block()
+ builder.emit_text_delta("answer")
+
+ tokens = builder.estimate_output_tokens()
+ assert tokens > 0
+
+ def test_estimate_empty(self):
+ builder = SSEBuilder("msg_1", "model")
+ tokens = builder.estimate_output_tokens()
+ assert tokens == 0
+
+ def test_estimate_without_tiktoken(self):
+ """Fallback estimation when tiktoken is not available."""
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_text_block()
+ builder.emit_text_delta("a" * 100) # 100 chars -> ~25 tokens
+
+ with patch("providers.common.sse_builder.ENCODER", None):
+ tokens = builder.estimate_output_tokens()
+ assert tokens == 25 # 100 // 4
+
+ def test_estimate_with_tools_no_tiktoken(self):
+ """Fallback tool token estimation."""
+ builder = SSEBuilder("msg_1", "model")
+ builder.start_tool_block(0, "t1", "Read")
+ builder.emit_tool_delta(0, '{"path":"test.py"}')
+
+ with patch("providers.common.sse_builder.ENCODER", None):
+ tokens = builder.estimate_output_tokens()
+ # 1 tool * 50 = 50
+ assert tokens == 50
diff --git a/Claude_Code/tests/providers/test_streaming_errors.py b/Claude_Code/tests/providers/test_streaming_errors.py
new file mode 100644
index 0000000000000000000000000000000000000000..21fce7c735b71648b7c4da2511b539b8aab51c1d
--- /dev/null
+++ b/Claude_Code/tests/providers/test_streaming_errors.py
@@ -0,0 +1,562 @@
+"""Tests for streaming error handling in providers/nvidia_nim/client.py."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from config.nim import NimSettings
+from providers.base import ProviderConfig
+from providers.nvidia_nim import NvidiaNimProvider
+
+
+class AsyncStreamMock:
+ """Async iterable mock that yields chunks then optionally raises."""
+
+ def __init__(self, chunks, error=None):
+ self._chunks = chunks
+ self._error = error
+
+ def __aiter__(self):
+ return self._aiter()
+
+ async def _aiter(self):
+ for chunk in self._chunks:
+ yield chunk
+ if self._error:
+ raise self._error
+
+
+def _make_provider():
+ """Create a provider instance for testing."""
+ config = ProviderConfig(
+ api_key="test_key",
+ base_url="https://test.api.nvidia.com/v1",
+ rate_limit=10,
+ rate_window=60,
+ )
+ return NvidiaNimProvider(config, nim_settings=NimSettings())
+
+
+def _make_request(model="test-model", stream=True):
+ """Create a mock request with all fields build_request_body needs."""
+ req = MagicMock()
+ req.model = model
+ req.stream = stream
+ req.messages = []
+ req.system = None
+ req.tools = None
+ req.tool_choice = None
+ req.metadata = None
+ req.max_tokens = 4096
+ req.temperature = None
+ req.top_p = None
+ req.top_k = None
+ req.stop_sequences = None
+ req.extra_body = None
+ req.thinking = None
+ return req
+
+
+def _make_chunk(
+ content=None, finish_reason=None, tool_calls=None, reasoning_content=None
+):
+ """Create a mock streaming chunk."""
+ delta = MagicMock()
+ delta.content = content
+ delta.tool_calls = tool_calls
+ delta.reasoning_content = reasoning_content if reasoning_content else None
+
+ choice = MagicMock()
+ choice.delta = delta
+ choice.finish_reason = finish_reason
+
+ chunk = MagicMock()
+ chunk.choices = [choice]
+ chunk.usage = None
+ return chunk
+
+
+async def _collect_stream(provider, request):
+ """Collect all SSE events from a stream."""
+ return [e async for e in provider.stream_response(request)]
+
+
+class TestStreamingExceptionHandling:
+ """Tests for error paths during stream_response."""
+
+ @pytest.mark.asyncio
+ async def test_api_error_emits_sse_error_event(self):
+ """When API raises during streaming, SSE error event is emitted."""
+ provider = _make_provider()
+ request = _make_request()
+
+ mock_stream = AsyncMock()
+ mock_stream.__aiter__ = MagicMock(side_effect=RuntimeError("API failed"))
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("API failed"),
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ # Should have message_start, error text block, close blocks, message_delta, message_stop
+ event_text = "".join(events)
+ assert "message_start" in event_text
+ assert "API failed" in event_text
+ assert "message_stop" in event_text
+
+ @pytest.mark.asyncio
+ async def test_read_timeout_with_empty_message_emits_fallback(self):
+ """ReadTimeout(TimeoutError()) should emit a visible, non-empty timeout message."""
+ provider = _make_provider()
+ request = _make_request()
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ side_effect=httpx.ReadTimeout(""),
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = [
+ e
+ async for e in provider.stream_response(
+ request,
+ request_id="req_timeout123",
+ )
+ ]
+
+ event_text = "".join(events)
+ assert "timed out after" in event_text
+ assert "request_id=req_timeout123" in event_text
+ assert "message_stop" in event_text
+
+ @pytest.mark.asyncio
+ async def test_error_after_partial_content(self):
+ """Error after partial content: blocks closed, error emitted."""
+ provider = _make_provider()
+ request = _make_request()
+
+ chunk1 = _make_chunk(content="Hello ")
+ stream_mock = AsyncStreamMock([chunk1], error=RuntimeError("Connection lost"))
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "Hello" in event_text
+ assert "Connection lost" in event_text
+ assert "message_stop" in event_text
+
+ @pytest.mark.asyncio
+ async def test_empty_response_gets_space(self):
+ """Empty response with no text/tools gets a single space text block."""
+ provider = _make_provider()
+ request = _make_request()
+
+ empty_chunk = _make_chunk(finish_reason="stop")
+ stream_mock = AsyncStreamMock([empty_chunk])
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert '"text_delta"' in event_text
+ assert "message_stop" in event_text
+
+ @pytest.mark.asyncio
+ async def test_stream_with_thinking_content(self):
+ """Thinking content via think tags is emitted as thinking blocks."""
+ provider = _make_provider()
+ request = _make_request()
+
+ chunk1 = _make_chunk(content="reasoninganswer")
+ chunk2 = _make_chunk(finish_reason="stop")
+ stream_mock = AsyncStreamMock([chunk1, chunk2])
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "thinking" in event_text
+ assert "reasoning" in event_text
+ assert "answer" in event_text
+
+ @pytest.mark.asyncio
+ async def test_stream_with_reasoning_content_field(self):
+ """reasoning_content delta field is emitted as thinking block."""
+ provider = _make_provider()
+ request = _make_request()
+
+ chunk1 = _make_chunk(reasoning_content="I think...")
+ chunk2 = _make_chunk(content="The answer")
+ chunk3 = _make_chunk(finish_reason="stop")
+ stream_mock = AsyncStreamMock([chunk1, chunk2, chunk3])
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "thinking_delta" in event_text
+ assert "I think..." in event_text
+ assert "The answer" in event_text
+
+ @pytest.mark.asyncio
+ async def test_stream_rate_limited_retries_via_execute_with_retry(self):
+ """When rate limited, execute_with_retry handles retries transparently."""
+ provider = _make_provider()
+ request = _make_request()
+
+ chunk1 = _make_chunk(content="Response")
+ chunk2 = _make_chunk(finish_reason="stop")
+ stream_mock = AsyncStreamMock([chunk1, chunk2])
+
+ with patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ):
+ # Mock execute_with_retry to pass through to the actual function
+ async def _passthrough(fn, *args, **kwargs):
+ return await fn(*args, **kwargs)
+
+ with patch.object(
+ provider._global_rate_limiter,
+ "execute_with_retry",
+ new_callable=AsyncMock,
+ side_effect=_passthrough,
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "Response" in event_text
+
+
+class TestProcessToolCall:
+ """Tests for _process_tool_call method."""
+
+ def test_tool_call_with_id(self):
+ """Tool call with id starts a tool block."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc = {
+ "index": 0,
+ "id": "call_123",
+ "function": {"name": "search", "arguments": '{"q": "test"}'},
+ }
+ events = list(provider._process_tool_call(tc, sse))
+ event_text = "".join(events)
+ assert "tool_use" in event_text
+ assert "search" in event_text
+ assert "call_123" in event_text
+
+ def test_tool_call_without_id_generates_uuid(self):
+ """Tool call without id generates a uuid-based id."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc = {
+ "index": 0,
+ "id": None,
+ "function": {"name": "test", "arguments": "{}"},
+ }
+ events = list(provider._process_tool_call(tc, sse))
+ event_text = "".join(events)
+ assert "tool_" in event_text
+
+ def test_task_tool_forces_background_false(self):
+ """Task tool with run_in_background=true is forced to false."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ args = json.dumps({"run_in_background": True, "prompt": "test"})
+ tc = {
+ "index": 0,
+ "id": "call_task",
+ "function": {"name": "Task", "arguments": args},
+ }
+ events = list(provider._process_tool_call(tc, sse))
+ event_text = "".join(events)
+ # The intercepted args should have run_in_background=false
+ assert "false" in event_text.lower()
+
+ def test_task_tool_chunked_args_forces_background_false(self):
+ """Chunked Task args are buffered until valid JSON, then forced to false."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc1 = {
+ "index": 0,
+ "id": "call_task_chunked",
+ "function": {"name": "Task", "arguments": '{"run_in_background": true,'},
+ }
+ tc2 = {
+ "index": 0,
+ "id": "call_task_chunked",
+ "function": {"name": None, "arguments": ' "prompt": "test"}'},
+ }
+
+ events1 = list(provider._process_tool_call(tc1, sse))
+ assert len(events1) > 0
+ assert "false" not in "".join(events1).lower()
+
+ events2 = list(provider._process_tool_call(tc2, sse))
+ event_text = "".join(events1 + events2)
+ assert "false" in event_text.lower()
+
+ def test_task_tool_invalid_json_logs_warning_on_flush(self, caplog):
+ """Invalid JSON args for Task tool emits {} on flush and logs a warning."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc = {
+ "index": 0,
+ "id": "call_task2",
+ "function": {"name": "Task", "arguments": "not json"},
+ }
+ events = list(provider._process_tool_call(tc, sse))
+ assert len(events) > 0
+
+ with caplog.at_level("WARNING"):
+ flushed = list(provider._flush_task_arg_buffers(sse))
+ assert len(flushed) > 0
+ assert "{}" in "".join(flushed)
+ assert any("Task args invalid JSON" in r.message for r in caplog.records)
+
+ def test_negative_tool_index_fallback(self):
+ """tc_index < 0 uses len(tool_indices) as fallback."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc = {
+ "index": -1,
+ "id": "call_neg",
+ "function": {"name": "test", "arguments": "{}"},
+ }
+ events = list(provider._process_tool_call(tc, sse))
+ # Should not crash, should still emit events
+ assert len(events) > 0
+
+ def test_tool_args_emitted_as_delta(self):
+ """Arguments are emitted as input_json_delta events."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc = {
+ "index": 0,
+ "id": "call_args",
+ "function": {"name": "grep", "arguments": '{"pattern": "test"}'},
+ }
+ events = list(provider._process_tool_call(tc, sse))
+ event_text = "".join(events)
+ assert "input_json_delta" in event_text
+
+
+class TestStreamChunkEdgeCases:
+ """Tests for edge cases in stream chunk handling."""
+
+ @pytest.mark.asyncio
+ async def test_stream_chunk_with_empty_choices_skipped(self):
+ """Chunk with choices=[] is skipped without crashing."""
+ provider = _make_provider()
+ request = _make_request()
+
+ empty_choices_chunk = MagicMock()
+ empty_choices_chunk.choices = []
+ empty_choices_chunk.usage = None
+
+ finish_chunk = _make_chunk(finish_reason="stop")
+ stream_mock = AsyncStreamMock([empty_choices_chunk, finish_chunk])
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "message_start" in event_text
+ assert "message_stop" in event_text
+
+ @pytest.mark.asyncio
+ async def test_stream_chunk_with_none_delta_handled(self):
+ """Chunk with choice.delta=None is handled defensively."""
+ provider = _make_provider()
+ request = _make_request()
+
+ none_delta_chunk = MagicMock()
+ none_delta_chunk.usage = None
+ choice = MagicMock()
+ choice.delta = None
+ choice.finish_reason = None
+ none_delta_chunk.choices = [choice]
+
+ finish_chunk = _make_chunk(finish_reason="stop")
+ stream_mock = AsyncStreamMock([none_delta_chunk, finish_chunk])
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "message_start" in event_text
+ assert "message_stop" in event_text
+
+ @pytest.mark.asyncio
+ async def test_stream_generator_cleanup_on_exception(self):
+ """When stream raises mid-iteration, message_stop still emitted."""
+ provider = _make_provider()
+ request = _make_request()
+
+ chunk1 = _make_chunk(content="Partial")
+ stream_mock = AsyncStreamMock(
+ [chunk1], error=ConnectionResetError("Connection reset")
+ )
+
+ with (
+ patch.object(
+ provider._client.chat.completions,
+ "create",
+ new_callable=AsyncMock,
+ return_value=stream_mock,
+ ),
+ patch.object(
+ provider._global_rate_limiter,
+ "wait_if_blocked",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ ):
+ events = await _collect_stream(provider, request)
+
+ event_text = "".join(events)
+ assert "Partial" in event_text
+ assert "Connection reset" in event_text
+ assert "message_stop" in event_text
+
+ def test_stream_malformed_tool_args_chunked(self):
+ """Chunked tool args that never form valid JSON are flushed with {}."""
+ provider = _make_provider()
+ from providers.common import SSEBuilder
+
+ sse = SSEBuilder("msg_test", "test-model")
+ tc1 = {
+ "index": 0,
+ "id": "call_malformed",
+ "function": {"name": "Task", "arguments": '{"broken":'},
+ }
+ tc2 = {
+ "index": 0,
+ "id": "call_malformed",
+ "function": {"name": None, "arguments": " never valid }"},
+ }
+
+ events1 = list(provider._process_tool_call(tc1, sse))
+ events2 = list(provider._process_tool_call(tc2, sse))
+ flushed = list(provider._flush_task_arg_buffers(sse))
+
+ event_text = "".join(events1 + events2 + flushed)
+ assert "tool_use" in event_text
+ assert "{}" in event_text
diff --git a/Claude_Code/tests/providers/test_subagent_interception.py b/Claude_Code/tests/providers/test_subagent_interception.py
new file mode 100644
index 0000000000000000000000000000000000000000..a36897aa1050d96d51b254af442e5d16fb34d2c2
--- /dev/null
+++ b/Claude_Code/tests/providers/test_subagent_interception.py
@@ -0,0 +1,54 @@
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+from config.nim import NimSettings
+from providers.base import ProviderConfig
+from providers.common import ContentBlockManager
+from providers.nvidia_nim import NvidiaNimProvider
+
+
+@pytest.mark.asyncio
+async def test_task_tool_interception():
+ # Setup provider
+ config = ProviderConfig(api_key="test")
+ provider = NvidiaNimProvider(config, nim_settings=NimSettings())
+
+ # Mock request and sse builder with real ContentBlockManager
+ request = MagicMock()
+ request.model = "test-model"
+
+ sse = MagicMock()
+ sse.blocks = ContentBlockManager()
+
+ # Tool call data (Task tool)
+ tc = {
+ "index": 0,
+ "id": "tool_123",
+ "function": {
+ "name": "Task",
+ "arguments": json.dumps(
+ {
+ "description": "test task",
+ "prompt": "do something",
+ "run_in_background": True,
+ }
+ ),
+ },
+ }
+
+ # Call the method (consume generator to trigger side effects)
+ list(provider._process_tool_call(tc, sse))
+
+ # Find the emit_tool_delta call and check args
+ calls = sse.emit_tool_delta.call_args_list
+ assert len(calls) > 0
+ args_passed = json.loads(calls[0][0][1])
+ assert args_passed["run_in_background"] is False
+
+
+if __name__ == "__main__":
+ import asyncio
+
+ asyncio.run(test_task_tool_interception())
diff --git a/Claude_Code/uv.lock b/Claude_Code/uv.lock
new file mode 100644
index 0000000000000000000000000000000000000000..afd063930f6e4b59860a4dd5ff598bbd2e762583
--- /dev/null
+++ b/Claude_Code/uv.lock
@@ -0,0 +1,3214 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version == '3.13.*'",
+ "python_full_version < '3.13'",
+]
+
+[[package]]
+name = "accelerate"
+version = "1.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "psutil" },
+ { name = "pyyaml" },
+ { name = "safetensors" },
+ { name = "torch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4a/8e/ac2a9566747a93f8be36ee08532eb0160558b07630a081a6056a9f89bf1d/accelerate-1.12.0.tar.gz", hash = "sha256:70988c352feb481887077d2ab845125024b2a137a5090d6d7a32b57d03a45df6", size = 398399, upload-time = "2025-11-21T11:27:46.973Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/d2/c581486aa6c4fbd7394c23c47b83fa1a919d34194e16944241daf9e762dd/accelerate-1.12.0-py3-none-any.whl", hash = "sha256:3e2091cd341423207e2f084a6654b1efcd250dc326f2a37d6dde446e07cabb11", size = 380935, upload-time = "2025-11-21T11:27:44.522Z" },
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
+ { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
+ { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
+ { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
+ { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
+ { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
+ { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
+ { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
+ { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
+ { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
+ { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
+ { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
+ { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
+ { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
+ { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
+ { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
+ { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[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 = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "audioop-lts"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" },
+ { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" },
+ { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" },
+ { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" },
+ { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" },
+ { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" },
+ { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" },
+ { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" },
+ { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" },
+ { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" },
+ { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" },
+ { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" },
+ { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" },
+ { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
+]
+
+[[package]]
+name = "audioread"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "standard-aifc", marker = "python_full_version >= '3.13'" },
+ { name = "standard-sunau", marker = "python_full_version >= '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/4a/874ecf9b472f998130c2b5e145dcdb9f6131e84786111489103b66772143/audioread-3.1.0.tar.gz", hash = "sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190", size = 20082, upload-time = "2025-10-26T19:44:13.484Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4", size = 23143, upload-time = "2025-10-26T19:44:12.016Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
+[[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.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[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 = "coverage"
+version = "7.13.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
+ { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
+ { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
+ { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
+ { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
+ { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
+ { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
+ { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
+ { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
+ { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
+ { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
+ { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
+ { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
+ { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
+ { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
+ { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
+ { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
+ { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
+ { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
+ { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
+ { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
+ { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
+ { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
+ { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
+ { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
+ { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
+ { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
+ { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
+]
+
+[[package]]
+name = "cuda-bindings"
+version = "13.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cuda-pathfinder" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/3c/c33fd3aa5fcc89aa1c135e477a0561f29142ab5fe028ca425fc87f7f0a74/cuda_bindings-13.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b899e5a513c11eaa18648f9bf5265d8de2a93f76ef66a6bfca0a2887303965cd", size = 11709086, upload-time = "2025-10-21T15:09:00.005Z" },
+ { url = "https://files.pythonhosted.org/packages/21/ac/6b34452a3836c9fbabcd360689a353409d15f500dd9d9ced7f837549e383/cuda_bindings-13.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf41d9e69019939aa15296fa66ea7d3fdb8d2c6383f729f4b1156c8b37808a06", size = 12128303, upload-time = "2025-10-21T15:09:02.889Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/36/41ccc303eb6be8ae82c5edd2ccae938876e8a794660e8bb96a193174a978/cuda_bindings-13.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb16a7f769c9c67469add7a1d9f6c14dd44637f6921cb6b9eb82cb5015b35c3d", size = 11537064, upload-time = "2025-10-21T15:09:07.84Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ac/699889100536f1b63779646291e74eefa818087a0974eb271314d850f5dc/cuda_bindings-13.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:512d0d803a5e47a8a42d5a34ce0932802bf72fe952fdb11ac798715a35c6e5cb", size = 11910447, upload-time = "2025-10-21T15:09:09.942Z" },
+ { url = "https://files.pythonhosted.org/packages/11/67/9656e003f18c5b32e1a2496998b24f4355ec978c5f3639b0eb9f6d0ff83f/cuda_bindings-13.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c859e326c776a47e66c50386a10c84fe34291eb6e711610c9fd7cc27d446334f", size = 11522409, upload-time = "2025-10-21T15:09:14.674Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d8/a83379caa7c1bed4195e704c24467a6c07fe8e29c7055ccd4f00c5702363/cuda_bindings-13.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e675dbd009fb5e66d63fd13a8ff35f849120f01bcc4dafadbced3004605c3588", size = 11903148, upload-time = "2025-10-21T15:09:16.918Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/99/0042dc5e98e3364480b1aaabc0f5c150d037825b264bba35ac7a883e46ee/cuda_bindings-13.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c7e6e89cdfc9b34f16a065cc6ad6c4bab19ce5dcef8da3ace8ad10bda899fa0", size = 11594384, upload-time = "2025-10-21T15:09:21.938Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/c4/a931a90ce763bd7d587e18e73e4ce246b8547c78247c4f50ee24efc0e984/cuda_bindings-13.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e93866465e7ff4b7ebdf711cf9cd680499cd875f992058c68be08d4775ac233d", size = 11920899, upload-time = "2025-10-21T15:09:26.306Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/2c/ec611e27ba48a9056f3b0610c5e27727e539f3905356cfe07acea18e772c/cuda_bindings-13.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed06ef3507bd0aefb0da367e3d15676a8c7443bd68a88f298562d60b41078c20", size = 11521928, upload-time = "2025-10-21T15:09:30.714Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/2e/02cebf281ef5201b6bb9ea193b1a4d26e6233c46571cfb04c4a7dede12b9/cuda_bindings-13.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ab845487ca2c14accdcb393a559a3070469ea4b591d05e6ef439471f47f3e24", size = 11902749, upload-time = "2025-10-21T15:09:32.688Z" },
+]
+
+[[package]]
+name = "cuda-pathfinder"
+version = "1.3.4"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/5e/db279a3bfbd18d59d0598922a3b3c1454908d0969e8372260afec9736376/cuda_pathfinder-1.3.4-py3-none-any.whl", hash = "sha256:fb983f6e0d43af27ef486e14d5989b5f904ef45cedf40538bfdcbffa6bb01fb2", size = 30878, upload-time = "2026-02-11T18:50:31.008Z" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+]
+
+[[package]]
+name = "discord-py"
+version = "2.6.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "audioop-lts", marker = "python_full_version >= '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" },
+]
+
+[[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 = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
+[[package]]
+name = "email-validator"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
+]
+
+[[package]]
+name = "execnet"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.129.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "email-validator" },
+ { name = "fastapi-cli", extra = ["standard"] },
+ { name = "httpx" },
+ { name = "jinja2" },
+ { name = "pydantic-extra-types" },
+ { name = "pydantic-settings" },
+ { name = "python-multipart" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[[package]]
+name = "fastapi-cli"
+version = "0.0.21"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "rich-toolkit" },
+ { name = "typer" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4a/5a/500ec4deaa9a5d6bc7909cbd7b252fa37fe80d418c55a65ce5ed11c53505/fastapi_cli-0.0.21.tar.gz", hash = "sha256:457134b8f3e08d2d203a18db923a18bbc1a01d9de36fbe1fa7905c4d02a0e5c0", size = 19664, upload-time = "2026-02-11T15:27:59.65Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/cf/d1f3ea2a1661d80c62c7b1537184ec28ec832eefb7ad1ff3047813d19452/fastapi_cli-0.0.21-py3-none-any.whl", hash = "sha256:57c6e043694c68618eee04d00b4d93213c37f5a854b369d2871a77dfeff57e91", size = 12391, upload-time = "2026-02-11T15:27:58.181Z" },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "fastapi-cloud-cli" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[[package]]
+name = "fastapi-cloud-cli"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fastar" },
+ { name = "httpx" },
+ { name = "pydantic", extra = ["email"] },
+ { name = "rich-toolkit" },
+ { name = "rignore" },
+ { name = "sentry-sdk" },
+ { name = "typer" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1b/59/3def056ec8350df78a0786b7ca40a167cbf28ac26552ced4e19e1f83e872/fastapi_cloud_cli-0.12.0.tar.gz", hash = "sha256:c897d1d5e27f5b4148ed2601076785155ec8fb385a6a62d3e8801880f929629f", size = 38508, upload-time = "2026-02-13T19:39:57.877Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/6f/badabb5a21388b0af2b9cd0c2a5d81aaecfca57bf382872890e802eaed98/fastapi_cloud_cli-0.12.0-py3-none-any.whl", hash = "sha256:9c666c2ab1684cee48a5b0a29ac1ae0bd395b9a13bf6858448b4369ea68beda1", size = 27735, upload-time = "2026-02-13T19:39:58.705Z" },
+]
+
+[[package]]
+name = "fastar"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/f1/5b2ff898abac7f1a418284aad285e3a4f68d189c572ab2db0f6c9079dd16/fastar-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f10d2adfe40f47ff228f4efaa32d409d732ded98580e03ed37c9535b5fc923d", size = 706369, upload-time = "2025-11-26T02:34:37.783Z" },
+ { url = "https://files.pythonhosted.org/packages/23/60/8046a386dca39154f80c927cbbeeb4b1c1267a3271bffe61552eb9995757/fastar-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b930da9d598e3bc69513d131f397e6d6be4643926ef3de5d33d1e826631eb036", size = 629097, upload-time = "2025-11-26T02:34:21.888Z" },
+ { url = "https://files.pythonhosted.org/packages/22/7e/1ae005addc789924a9268da2394d3bb5c6f96836f7e37b7e3d23c2362675/fastar-0.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d210da2de733ca801de83e931012349d209f38b92d9630ccaa94bd445bdc9b8", size = 868938, upload-time = "2025-11-26T02:33:51.119Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/77/290a892b073b84bf82e6b2259708dfe79c54f356e252c2dd40180b16fe07/fastar-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa02270721517078a5bd61a38719070ac2537a4aa6b6c48cf369cf2abc59174a", size = 765204, upload-time = "2025-11-26T02:32:47.02Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/00/c3155171b976003af3281f5258189f1935b15d1221bfc7467b478c631216/fastar-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c391e5b789a720e4d0029b9559f5d6dee3226693c5b39c0eab8eaece997e0f", size = 764717, upload-time = "2025-11-26T02:33:02.453Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/43/405b7ad76207b2c11b7b59335b70eac19e4a2653977f5588a1ac8fed54f4/fastar-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3258d7a78a72793cdd081545da61cabe85b1f37634a1d0b97ffee0ff11d105ef", size = 931502, upload-time = "2025-11-26T02:33:18.619Z" },
+ { url = "https://files.pythonhosted.org/packages/da/8a/a3dde6d37cc3da4453f2845cdf16675b5686b73b164f37e2cc579b057c2c/fastar-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6eab95dd985cdb6a50666cbeb9e4814676e59cfe52039c880b69d67cfd44767", size = 821454, upload-time = "2025-11-26T02:33:33.427Z" },
+ { url = "https://files.pythonhosted.org/packages/da/c1/904fe2468609c8990dce9fe654df3fbc7324a8d8e80d8240ae2c89757064/fastar-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:829b1854166141860887273c116c94e31357213fa8e9fe8baeb18bd6c38aa8d9", size = 821647, upload-time = "2025-11-26T02:34:07Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/73/a0642ab7a400bc07528091785e868ace598fde06fcd139b8f865ec1b6f3c/fastar-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1667eae13f9457a3c737f4376d68e8c3e548353538b28f7e4273a30cb3965cd", size = 986342, upload-time = "2025-11-26T02:34:53.371Z" },
+ { url = "https://files.pythonhosted.org/packages/af/af/60c1bfa6edab72366461a95f053d0f5f7ab1825fe65ca2ca367432cd8629/fastar-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b864a95229a7db0814cd9ef7987cb713fd43dce1b0d809dd17d9cd6f02fdde3e", size = 1040207, upload-time = "2025-11-26T02:35:10.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/a0/0d624290dec622e7fa084b6881f456809f68777d54a314f5dde932714506/fastar-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c05fbc5618ce17675a42576fa49858d79734627f0a0c74c0875ab45ee8de340c", size = 1045031, upload-time = "2025-11-26T02:35:28.108Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/74/cf663af53c4706ba88e6b4af44a6b0c3bd7d7ca09f079dc40647a8f06585/fastar-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7f41c51ee96f338662ee3c3df4840511ba3f9969606840f1b10b7cb633a3c716", size = 994877, upload-time = "2025-11-26T02:35:45.797Z" },
+ { url = "https://files.pythonhosted.org/packages/52/17/444c8be6e77206050e350da7c338102b6cab384be937fa0b1d6d1f9ede73/fastar-0.8.0-cp312-cp312-win32.whl", hash = "sha256:d949a1a2ea7968b734632c009df0571c94636a5e1622c87a6e2bf712a7334f47", size = 455996, upload-time = "2025-11-26T02:36:26.938Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/34/fc3b5e56d71a17b1904800003d9251716e8fd65f662e1b10a26881698a74/fastar-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc645994d5b927d769121094e8a649b09923b3c13a8b0b98696d8f853f23c532", size = 490429, upload-time = "2025-11-26T02:36:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/35/a8/5608cc837417107c594e2e7be850b9365bcb05e99645966a5d6a156285fe/fastar-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:d81ee82e8dc78a0adb81728383bd39611177d642a8fa2d601d4ad5ad59e5f3bd", size = 461297, upload-time = "2025-11-26T02:36:03.546Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" },
+ { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" },
+ { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" },
+ { url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" },
+ { url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" },
+ { url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" },
+ { url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" },
+ { url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" },
+ { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" },
+ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.24.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/a8/dae62680be63cbb3ff87cfa2f51cf766269514ea5488479d42fec5aa6f3a/filelock-3.24.2.tar.gz", hash = "sha256:c22803117490f156e59fafce621f0550a7a853e2bbf4f87f112b11d469b6c81b", size = 37601, upload-time = "2026-02-16T02:50:45.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/04/a94ebfb4eaaa08db56725a40de2887e95de4e8641b9e902c311bfa00aa39/filelock-3.24.2-py3-none-any.whl", hash = "sha256:667d7dc0b7d1e1064dd5f8f8e80bdac157a6482e8d2e02cd16fd3b6b33bd6556", size = 24152, upload-time = "2026-02-16T02:50:44Z" },
+]
+
+[[package]]
+name = "free-claude-code"
+version = "2.0.0"
+source = { editable = "." }
+dependencies = [
+ { name = "discord-py" },
+ { name = "fastapi", extra = ["standard"] },
+ { name = "httpx" },
+ { name = "loguru" },
+ { name = "markdown-it-py" },
+ { name = "openai" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "python-dotenv" },
+ { name = "python-telegram-bot" },
+ { name = "tiktoken" },
+ { name = "uvicorn" },
+]
+
+[package.optional-dependencies]
+voice = [
+ { name = "grpcio" },
+ { name = "grpcio-tools" },
+ { name = "nvidia-riva-client" },
+]
+voice-local = [
+ { name = "accelerate" },
+ { name = "librosa" },
+ { name = "torch" },
+ { name = "transformers" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "pytest-xdist" },
+ { name = "ruff" },
+ { name = "ty" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "accelerate", marker = "extra == 'voice-local'", specifier = ">=0.30.0" },
+ { name = "discord-py", specifier = ">=2.0.0" },
+ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.11" },
+ { name = "grpcio", marker = "extra == 'voice'", specifier = ">=1.78.0" },
+ { name = "grpcio-tools", marker = "extra == 'voice'", specifier = ">=1.78.0" },
+ { name = "httpx", specifier = ">=0.25.0" },
+ { name = "librosa", marker = "extra == 'voice-local'", specifier = ">=0.10.0" },
+ { name = "loguru", specifier = ">=0.7.0" },
+ { name = "markdown-it-py", specifier = ">=3.0.0" },
+ { name = "nvidia-riva-client", marker = "extra == 'voice'", specifier = ">=2.15.0" },
+ { name = "openai", specifier = ">=2.16.0" },
+ { name = "pydantic", specifier = ">=2.0.0" },
+ { name = "pydantic-settings", specifier = ">=2.12.0" },
+ { name = "python-dotenv", specifier = ">=1.0.0" },
+ { name = "python-telegram-bot", specifier = ">=21.0" },
+ { name = "tiktoken", specifier = ">=0.7.0" },
+ { name = "torch", marker = "extra == 'voice-local'", specifier = ">=2.0.0", index = "https://download.pytorch.org/whl/cu130" },
+ { name = "transformers", marker = "extra == 'voice-local'", specifier = ">=4.45.0" },
+ { name = "uvicorn", specifier = ">=0.34.0" },
+]
+provides-extras = ["voice", "voice-local"]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=9.0.2" },
+ { name = "pytest-asyncio", specifier = ">=1.3.0" },
+ { name = "pytest-cov", specifier = ">=7.0.0" },
+ { name = "pytest-xdist", specifier = ">=3.8.0" },
+ { name = "ruff", specifier = ">=0.9.0" },
+ { name = "ty", specifier = ">=0.0.1" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
+]
+
+[[package]]
+name = "grpcio"
+version = "1.78.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" },
+ { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" },
+ { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" },
+ { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" },
+ { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" },
+ { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" },
+ { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" },
+ { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" },
+ { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" },
+ { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" },
+ { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" },
+ { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" },
+ { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" },
+ { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" },
+]
+
+[[package]]
+name = "grpcio-tools"
+version = "1.78.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "grpcio" },
+ { name = "protobuf" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/d1/cbefe328653f746fd319c4377836a25ba64226e41c6a1d7d5cdbc87a459f/grpcio_tools-1.78.0.tar.gz", hash = "sha256:4b0dd86560274316e155d925158276f8564508193088bc43e20d3f5dff956b2b", size = 5393026, upload-time = "2026-02-06T09:59:59.53Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/ae/5b1fa5dd8d560a6925aa52de0de8731d319f121c276e35b9b2af7cc220a2/grpcio_tools-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:9eb122da57d4cad7d339fc75483116f0113af99e8d2c67f3ef9cae7501d806e4", size = 2546823, upload-time = "2026-02-06T09:58:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ed/d33ccf7fa701512efea7e7e23333b748848a123e9d3bbafde4e126784546/grpcio_tools-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d0c501b8249940b886420e6935045c44cb818fa6f265f4c2b97d5cff9cb5e796", size = 5706776, upload-time = "2026-02-06T09:58:20.944Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/69/4285583f40b37af28277fc6b867d636e3b10e1b6a7ebd29391a856e1279b/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:77e5aa2d2a7268d55b1b113f958264681ef1994c970f69d48db7d4683d040f57", size = 2593972, upload-time = "2026-02-06T09:58:23.29Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/eb/ecc1885bd6b3147f0a1b7dff5565cab72f01c8f8aa458f682a1c77a9fb08/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e3c0b0e6ba5275322ba29a97bf890565a55f129f99a21b121145e9e93a22525", size = 2905531, upload-time = "2026-02-06T09:58:25.406Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/a9/511d0040ced66960ca10ba0f082d6b2d2ee6dd61837b1709636fdd8e23b4/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975d4cb48694e20ebd78e1643e5f1cd94cdb6a3d38e677a8e84ae43665aa4790", size = 2656909, upload-time = "2026-02-06T09:58:28.022Z" },
+ { url = "https://files.pythonhosted.org/packages/06/a3/3d2c707e7dee8df842c96fbb24feb2747e506e39f4a81b661def7fed107c/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:553ff18c5d52807dedecf25045ae70bad7a3dbba0b27a9a3cdd9bcf0a1b7baec", size = 3109778, upload-time = "2026-02-06T09:58:30.091Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/4b/646811ba241bf05da1f0dc6f25764f1c837f78f75b4485a4210c84b79eae/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8c7f5e4af5a84d2e96c862b1a65e958a538237e268d5f8203a3a784340975b51", size = 3658763, upload-time = "2026-02-06T09:58:32.875Z" },
+ { url = "https://files.pythonhosted.org/packages/45/de/0a5ef3b3e79d1011375f5580dfee3a9c1ccb96c5f5d1c74c8cee777a2483/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96183e2b44afc3f9a761e9d0f985c3b44e03e8bb98e626241a6cbfb3b6f7e88f", size = 3325116, upload-time = "2026-02-06T09:58:34.894Z" },
+ { url = "https://files.pythonhosted.org/packages/95/d2/6391b241ad571bc3e71d63f957c0b1860f0c47932d03c7f300028880f9b8/grpcio_tools-1.78.0-cp312-cp312-win32.whl", hash = "sha256:2250e8424c565a88573f7dc10659a0b92802e68c2a1d57e41872c9b88ccea7a6", size = 993493, upload-time = "2026-02-06T09:58:37.242Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/8f/7d0d3a39ecad76ccc136be28274daa660569b244fa7d7d0bbb24d68e5ece/grpcio_tools-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:217d1fa29de14d9c567d616ead7cb0fef33cde36010edff5a9390b00d52e5094", size = 1158423, upload-time = "2026-02-06T09:58:40.072Z" },
+ { url = "https://files.pythonhosted.org/packages/53/ce/17311fb77530420e2f441e916b347515133e83d21cd6cc77be04ce093d5b/grpcio_tools-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2d6de1cc23bdc1baafc23e201b1e48c617b8c1418b4d8e34cebf72141676e5fb", size = 2546284, upload-time = "2026-02-06T09:58:43.073Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/d3/79e101483115f0e78223397daef71751b75eba7e92a32060c10aae11ca64/grpcio_tools-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2afeaad88040894c76656202ff832cb151bceb05c0e6907e539d129188b1e456", size = 5705653, upload-time = "2026-02-06T09:58:45.533Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/a7/52fa3ccb39ceeee6adc010056eadfbca8198651c113e418dafebbdf2b306/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33cc593735c93c03d63efe7a8ba25f3c66f16c52f0651910712490244facad72", size = 2592788, upload-time = "2026-02-06T09:58:48.918Z" },
+ { url = "https://files.pythonhosted.org/packages/68/08/682ff6bb548225513d73dc9403742d8975439d7469c673bc534b9bbc83a7/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2921d7989c4d83b71f03130ab415fa4d66e6693b8b8a1fcbb7a1c67cff19b812", size = 2905157, upload-time = "2026-02-06T09:58:51.478Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/66/264f3836a96423b7018e5ada79d62576a6401f6da4e1f4975b18b2be1265/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6a0df438e82c804c7b95e3f311c97c2f876dcc36376488d5b736b7bcf5a9b45", size = 2656166, upload-time = "2026-02-06T09:58:54.117Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6b/f108276611522e03e98386b668cc7e575eff6952f2db9caa15b2a3b3e883/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9c6070a9500798225191ef25d0055a15d2c01c9c8f2ee7b681fffa99c98c822", size = 3109110, upload-time = "2026-02-06T09:58:56.891Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c7/cf048dbcd64b3396b3c860a2ffbcc67a8f8c87e736aaa74c2e505a7eee4c/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:394e8b57d85370a62e5b0a4d64c96fcf7568345c345d8590c821814d227ecf1d", size = 3657863, upload-time = "2026-02-06T09:58:59.176Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/37/e2736912c8fda57e2e57a66ea5e0bc8eb9a5fb7ded00e866ad22d50afb08/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3ef700293ab375e111a2909d87434ed0a0b086adf0ce67a8d9cf12ea7765e63", size = 3324748, upload-time = "2026-02-06T09:59:01.242Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/726abc75bb5bfc2841e88ea05896e42f51ca7c30cb56da5c5b63058b3867/grpcio_tools-1.78.0-cp313-cp313-win32.whl", hash = "sha256:6993b960fec43a8d840ee5dc20247ef206c1a19587ea49fe5e6cc3d2a09c1585", size = 993074, upload-time = "2026-02-06T09:59:03.085Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/68/91b400bb360faf9b177ffb5540ec1c4d06ca923691ddf0f79e2c9683f4da/grpcio_tools-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:275ce3c2978842a8cf9dd88dce954e836e590cf7029649ad5d1145b779039ed5", size = 1158185, upload-time = "2026-02-06T09:59:05.036Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/5e/278f3831c8d56bae02e3acc570465648eccf0a6bbedcb1733789ac966803/grpcio_tools-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b080d0d072e6032708a3a91731b808074d7ab02ca8fb9847b6a011fdce64cd9", size = 2546270, upload-time = "2026-02-06T09:59:07.426Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/d9/68582f2952b914b60dddc18a2e3f9c6f09af9372b6f6120d6cf3ec7f8b4e/grpcio_tools-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8c0ad8f8f133145cd7008b49cb611a5c6a9d89ab276c28afa17050516e801f79", size = 5705731, upload-time = "2026-02-06T09:59:09.856Z" },
+ { url = "https://files.pythonhosted.org/packages/70/68/feb0f9a48818ee1df1e8b644069379a1e6ef5447b9b347c24e96fd258e5d/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f8ea092a7de74c6359335d36f0674d939a3c7e1a550f4c2c9e80e0226de8fe4", size = 2593896, upload-time = "2026-02-06T09:59:12.23Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/08/a430d8d06e1b8d33f3e48d3f0cc28236723af2f35e37bd5c8db05df6c3aa/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:da422985e0cac822b41822f43429c19ecb27c81ffe3126d0b74e77edec452608", size = 2905298, upload-time = "2026-02-06T09:59:14.458Z" },
+ { url = "https://files.pythonhosted.org/packages/71/0a/348c36a3eae101ca0c090c9c3bc96f2179adf59ee0c9262d11cdc7bfe7db/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fab1faa3fbcb246263e68da7a8177d73772283f9db063fb8008517480888d26", size = 2656186, upload-time = "2026-02-06T09:59:16.949Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/3f/18219f331536fad4af6207ade04142292faa77b5cb4f4463787988963df8/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dd9c094f73f734becae3f20f27d4944d3cd8fb68db7338ee6c58e62fc5c3d99f", size = 3109859, upload-time = "2026-02-06T09:59:19.202Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d9/341ea20a44c8e5a3a18acc820b65014c2e3ea5b4f32a53d14864bcd236bc/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2ed51ce6b833068f6c580b73193fc2ec16468e6bc18354bc2f83a58721195a58", size = 3657915, upload-time = "2026-02-06T09:59:21.839Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/f4/5978b0f91611a64371424c109dd0027b247e5b39260abad2eaee66b6aa37/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05803a5cdafe77c8bdf36aa660ad7a6a1d9e49bc59ce45c1bade2a4698826599", size = 3324724, upload-time = "2026-02-06T09:59:24.402Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/80/96a324dba99cfbd20e291baf0b0ae719dbb62b76178c5ce6c788e7331cb1/grpcio_tools-1.78.0-cp314-cp314-win32.whl", hash = "sha256:f7c722e9ce6f11149ac5bddd5056e70aaccfd8168e74e9d34d8b8b588c3f5c7c", size = 1015505, upload-time = "2026-02-06T09:59:26.3Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/d1/909e6a05bfd44d46327dc4b8a78beb2bae4fb245ffab2772e350081aaf7e/grpcio_tools-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d58ade518b546120ec8f0a8e006fc8076ae5df151250ebd7e82e9b5e152c229", size = 1190196, upload-time = "2026-02-06T09:59:28.359Z" },
+]
+
+[[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 = "hf-xet"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" },
+ { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" },
+ { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" },
+ { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" },
+ { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" },
+ { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" },
+]
+
+[[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 = "httptools"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
+ { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
+ { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
+ { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
+ { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
+ { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
+ { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
+ { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
+ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
+]
+
+[[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 = "huggingface-hub"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "httpx" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "shellingham" },
+ { name = "tqdm" },
+ { name = "typer-slim" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[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.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" },
+ { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" },
+ { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" },
+ { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" },
+ { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" },
+ { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" },
+ { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" },
+ { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" },
+ { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" },
+ { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" },
+ { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" },
+ { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" },
+ { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" },
+ { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" },
+ { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" },
+ { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" },
+ { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" },
+ { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" },
+ { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" },
+ { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" },
+ { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" },
+ { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" },
+ { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" },
+ { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" },
+ { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" },
+ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
+]
+
+[[package]]
+name = "joblib"
+version = "1.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+]
+
+[[package]]
+name = "lazy-loader"
+version = "0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" },
+]
+
+[[package]]
+name = "librosa"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "audioread" },
+ { name = "decorator" },
+ { name = "joblib" },
+ { name = "lazy-loader" },
+ { name = "msgpack" },
+ { name = "numba" },
+ { name = "numpy" },
+ { name = "pooch" },
+ { name = "scikit-learn" },
+ { name = "scipy" },
+ { name = "soundfile" },
+ { name = "soxr" },
+ { name = "standard-aifc", marker = "python_full_version >= '3.13'" },
+ { name = "standard-sunau", marker = "python_full_version >= '3.13'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/64/36/360b5aafa0238e29758729e9486c6ed92a6f37fa403b7875e06c115cdf4a/librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908", size = 327001, upload-time = "2025-03-11T15:09:54.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload-time = "2025-03-11T15:09:52.982Z" },
+]
+
+[[package]]
+name = "llvmlite"
+version = "0.46.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172", size = 37232767, upload-time = "2025-12-08T18:15:13.22Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/54/737755c0a91558364b9200702c3c9c15d70ed63f9b98a2c32f1c2aa1f3ba/llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54", size = 56275176, upload-time = "2025-12-08T18:15:16.339Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/91/14f32e1d70905c1c0aa4e6609ab5d705c3183116ca02ac6df2091868413a/llvmlite-0.46.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bca185892908f9ede48c0acd547fe4dc1bafefb8a4967d47db6cf664f9332d12", size = 55128629, upload-time = "2025-12-08T18:15:19.493Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a7/d526ae86708cea531935ae777b6dbcabe7db52718e6401e0fb9c5edea80e/llvmlite-0.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:67438fd30e12349ebb054d86a5a1a57fd5e87d264d2451bcfafbbbaa25b82a35", size = 38138941, upload-time = "2025-12-08T18:15:22.536Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/c9/d57877759d707e84c082163c543853245f91b70c804115a5010532890f18/llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269", size = 55128628, upload-time = "2025-12-08T18:15:31.098Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" },
+]
+
+[[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 = "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 = "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 = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "msgpack"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
+ { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
+ { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
+ { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
+ { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
+ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
+ { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
+ { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
+ { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
+ { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
+ { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
+ { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
+ { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
+ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
+ { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
+ { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
+ { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
+ { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
+ { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
+ { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
+ { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
+ { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "numba"
+version = "0.63.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "llvmlite" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/9c/c0974cd3d00ff70d30e8ff90522ba5fbb2bcee168a867d2321d8d0457676/numba-0.63.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2819cd52afa5d8d04e057bdfd54367575105f8829350d8fb5e4066fb7591cc71", size = 2680981, upload-time = "2025-12-10T02:57:17.579Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/70/ea2bc45205f206b7a24ee68a159f5097c9ca7e6466806e7c213587e0c2b1/numba-0.63.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cfd45dbd3d409e713b1ccfdc2ee72ca82006860254429f4ef01867fdba5845f", size = 3801656, upload-time = "2025-12-10T02:57:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/82/4f4ba4fd0f99825cbf3cdefd682ca3678be1702b63362011de6e5f71f831/numba-0.63.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a599df6976c03b7ecf15d05302696f79f7e6d10d620367407517943355bcb0", size = 3501857, upload-time = "2025-12-10T02:57:20.721Z" },
+ { url = "https://files.pythonhosted.org/packages/af/fd/6540456efa90b5f6604a86ff50dabefb187e43557e9081adcad3be44f048/numba-0.63.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbad8c63e4fc7eb3cdb2c2da52178e180419f7969f9a685f283b313a70b92af3", size = 2750282, upload-time = "2025-12-10T02:57:22.474Z" },
+ { url = "https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:0bd4fd820ef7442dcc07da184c3f54bb41d2bdb7b35bacf3448e73d081f730dc", size = 2680954, upload-time = "2025-12-10T02:57:24.145Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6c/1e222edba1e20e6b113912caa9b1665b5809433cbcb042dfd133c6f1fd38/numba-0.63.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53de693abe4be3bd4dee38e1c55f01c55ff644a6a3696a3670589e6e4c39cde2", size = 3809736, upload-time = "2025-12-10T02:57:25.836Z" },
+ { url = "https://files.pythonhosted.org/packages/76/0a/590bad11a8b3feeac30a24d01198d46bdb76ad15c70d3a530691ce3cae58/numba-0.63.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81227821a72a763c3d4ac290abbb4371d855b59fdf85d5af22a47c0e86bf8c7e", size = 3508854, upload-time = "2025-12-10T02:57:27.438Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f5/3800384a24eed1e4d524669cdbc0b9b8a628800bb1e90d7bd676e5f22581/numba-0.63.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb227b07c2ac37b09432a9bda5142047a2d1055646e089d4a240a2643e508102", size = 2750228, upload-time = "2025-12-10T02:57:30.36Z" },
+ { url = "https://files.pythonhosted.org/packages/36/2f/53be2aa8a55ee2608ebe1231789cbb217f6ece7f5e1c685d2f0752e95a5b/numba-0.63.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f180883e5508940cc83de8a8bea37fc6dd20fbe4e5558d4659b8b9bef5ff4731", size = 2681153, upload-time = "2025-12-10T02:57:32.016Z" },
+ { url = "https://files.pythonhosted.org/packages/13/91/53e59c86759a0648282368d42ba732c29524a745fd555ed1fb1df83febbe/numba-0.63.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0938764afa82a47c0e895637a6c55547a42c9e1d35cac42285b1fa60a8b02bb", size = 3778718, upload-time = "2025-12-10T02:57:33.764Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/0c/2be19eba50b0b7636f6d1f69dfb2825530537708a234ba1ff34afc640138/numba-0.63.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f90a929fa5094e062d4e0368ede1f4497d5e40f800e80aa5222c4734236a2894", size = 3478712, upload-time = "2025-12-10T02:57:35.518Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/5f/4d0c9e756732577a52211f31da13a3d943d185f7fb90723f56d79c696caa/numba-0.63.1-cp314-cp314-win_amd64.whl", hash = "sha256:8d6d5ce85f572ed4e1a135dbb8c0114538f9dd0e3657eeb0bb64ab204cbe2a8f", size = 2752161, upload-time = "2025-12-10T02:57:37.12Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.3.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" },
+ { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" },
+ { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" },
+ { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" },
+ { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" },
+ { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" },
+ { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" },
+ { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" },
+ { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" },
+ { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" },
+ { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" },
+ { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" },
+ { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" },
+ { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" },
+ { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" },
+ { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" },
+ { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" },
+ { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" },
+ { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" },
+ { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" },
+ { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" },
+ { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" },
+ { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" },
+]
+
+[[package]]
+name = "nvidia-cublas"
+version = "13.1.0.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" },
+ { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime"
+version = "13.0.96"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu13"
+version = "9.15.1.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/93/b3c9db2c35d6183361333d2dcfea50e094c012d012c8a4d7effbfb53ef62/nvidia_cudnn_cu13-9.15.1.9-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:44cd2ec83c3ef62a7357614bd02ce7f3dac35ffcbb04ad20999e730741f0ba17", size = 415636241, upload-time = "2025-11-12T20:22:26.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/d0/90f98fc55c48a7d8f5ad0a03a6321acc1a7024bdd550d96b3547a04ea6b4/nvidia_cudnn_cu13-9.15.1.9-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ebc9a647918df0d7298d67cfaf41579fd4c78ead9aba246f5ad9414d61b9ec4c", size = 351298418, upload-time = "2025-11-12T20:23:21.455Z" },
+]
+
+[[package]]
+name = "nvidia-cufft"
+version = "12.0.0.61"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" },
+]
+
+[[package]]
+name = "nvidia-cufile"
+version = "1.15.1.6"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" },
+]
+
+[[package]]
+name = "nvidia-curand"
+version = "10.4.0.35"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver"
+version = "12.0.4.66"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas" },
+ { name = "nvidia-cusparse" },
+ { name = "nvidia-nvjitlink" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse"
+version = "12.6.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" },
+]
+
+[[package]]
+name = "nvidia-cusparselt-cu13"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu13"
+version = "2.28.9"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" },
+]
+
+[[package]]
+name = "nvidia-nvshmem-cu13"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" },
+]
+
+[[package]]
+name = "nvidia-riva-client"
+version = "2.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "grpcio-tools" },
+ { name = "setuptools" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/82/0484c225bebe7ed37334474fba5c6ac7228638e692b84da0a0e7f2395672/nvidia_riva_client-2.16.0-py3-none-any.whl", hash = "sha256:99ef37b8f487d75a70c053736848221e09b728e5c910fb476333d375bd4347a3", size = 45491, upload-time = "2024-07-02T14:54:22.63Z" },
+]
+
+[[package]]
+name = "openai"
+version = "2.21.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/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
+]
+
+[[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 = "pooch"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "platformdirs" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "6.33.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
+ { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
+ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[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.optional-dependencies]
+email = [
+ { name = "email-validator" },
+]
+
+[[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-extra-types"
+version = "2.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" },
+]
+
+[[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 = "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 = "pytest-cov"
+version = "7.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+]
+
+[[package]]
+name = "pytest-xdist"
+version = "3.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "execnet" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
+]
+
+[[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.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
+]
+
+[[package]]
+name = "python-telegram-bot"
+version = "22.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpcore", marker = "python_full_version >= '3.14'" },
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761, upload-time = "2026-01-24T13:57:00.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0", size = 737267, upload-time = "2026-01-24T13:56:58.06Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.1.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" },
+ { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" },
+ { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" },
+ { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
+ { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
+ { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
+ { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
+ { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
+ { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
+ { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
+ { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
+ { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
+ { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
+ { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
+ { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
+ { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
+ { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
+ { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
+ { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
+ { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
+ { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
+]
+
+[[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 = "rich"
+version = "14.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
+]
+
+[[package]]
+name = "rich-toolkit"
+version = "0.19.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" },
+]
+
+[[package]]
+name = "rignore"
+version = "0.7.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" },
+ { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" },
+ { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" },
+ { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" },
+ { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" },
+ { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" },
+ { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" },
+ { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" },
+ { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" },
+ { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
+ { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
+ { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
+ { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
+ { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
+ { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
+ { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
+ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
+ { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
+ { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
+]
+
+[[package]]
+name = "safetensors"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
+ { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
+ { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
+ { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
+]
+
+[[package]]
+name = "scikit-learn"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "joblib" },
+ { name = "numpy" },
+ { name = "scipy" },
+ { name = "threadpoolctl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
+ { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
+ { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
+ { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
+ { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
+ { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
+ { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
+ { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
+ { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
+ { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
+ { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
+ { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
+ { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" },
+ { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" },
+ { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" },
+ { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" },
+ { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" },
+ { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" },
+ { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" },
+ { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" },
+ { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" },
+ { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" },
+ { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" },
+ { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" },
+ { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" },
+ { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" },
+ { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" },
+ { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" },
+ { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.52.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "78.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/9c/42314ee079a3e9c24b27515f9fbc7a3c1d29992c33451779011c74488375/setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d", size = 1368163, upload-time = "2025-04-19T18:23:36.68Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", size = 1256462, upload-time = "2025-04-19T18:23:34.525Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[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 = "soundfile"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" },
+ { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" },
+]
+
+[[package]]
+name = "soxr"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/7e/f4b461944662ad75036df65277d6130f9411002bfb79e9df7dff40a31db9/soxr-1.0.0.tar.gz", hash = "sha256:e07ee6c1d659bc6957034f4800c60cb8b98de798823e34d2a2bba1caa85a4509", size = 171415, upload-time = "2025-09-07T13:22:21.317Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/c7/f92b81f1a151c13afb114f57799b86da9330bec844ea5a0d3fe6a8732678/soxr-1.0.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:abecf4e39017f3fadb5e051637c272ae5778d838e5c3926a35db36a53e3a607f", size = 205508, upload-time = "2025-09-07T13:22:01.252Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1d/c945fea9d83ea1f2be9d116b3674dbaef26ed090374a77c394b31e3b083b/soxr-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:e973d487ee46aa8023ca00a139db6e09af053a37a032fe22f9ff0cc2e19c94b4", size = 163568, upload-time = "2025-09-07T13:22:03.558Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/80/10640970998a1d2199bef6c4d92205f36968cddaf3e4d0e9fe35ddd405bd/soxr-1.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ce273cca101aff3d8c387db5a5a41001ba76ef1837883438d3c652507a9ccc", size = 204707, upload-time = "2025-09-07T13:22:05.125Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/87/2726603c13c2126cb8ded9e57381b7377f4f0df6ba4408e1af5ddbfdc3dd/soxr-1.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f2a69686f2856d37823bbb7b78c3d44904f311fe70ba49b893af11d6b6047b", size = 238032, upload-time = "2025-09-07T13:22:06.428Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/04/530252227f4d0721a5524a936336485dfb429bb206a66baf8e470384f4a2/soxr-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:2a3b77b115ae7c478eecdbd060ed4f61beda542dfb70639177ac263aceda42a2", size = 172070, upload-time = "2025-09-07T13:22:07.62Z" },
+ { url = "https://files.pythonhosted.org/packages/99/77/d3b3c25b4f1b1aa4a73f669355edcaee7a52179d0c50407697200a0e55b9/soxr-1.0.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:392a5c70c04eb939c9c176bd6f654dec9a0eaa9ba33d8f1024ed63cf68cdba0a", size = 209509, upload-time = "2025-09-07T13:22:08.773Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/ee/3ca73e18781bb2aff92b809f1c17c356dfb9a1870652004bd432e79afbfa/soxr-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc41a1027ba46777186f26a8fba7893be913383414135577522da2fcc684490", size = 167690, upload-time = "2025-09-07T13:22:10.259Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/f0/eea8b5f587a2531657dc5081d2543a5a845f271a3bea1c0fdee5cebde021/soxr-1.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:449acd1dfaf10f0ce6dfd75c7e2ef984890df94008765a6742dafb42061c1a24", size = 209541, upload-time = "2025-09-07T13:22:11.739Z" },
+ { url = "https://files.pythonhosted.org/packages/64/59/2430a48c705565eb09e78346950b586f253a11bd5313426ced3ecd9b0feb/soxr-1.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38b35c99e408b8f440c9376a5e1dd48014857cd977c117bdaa4304865ae0edd0", size = 243025, upload-time = "2025-09-07T13:22:12.877Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/1b/f84a2570a74094e921bbad5450b2a22a85d58585916e131d9b98029c3e69/soxr-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a39b519acca2364aa726b24a6fd55acf29e4c8909102e0b858c23013c38328e5", size = 184850, upload-time = "2025-09-07T13:22:14.068Z" },
+]
+
+[[package]]
+name = "standard-aifc"
+version = "3.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "audioop-lts", marker = "python_full_version >= '3.13'" },
+ { name = "standard-chunk", marker = "python_full_version >= '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" },
+]
+
+[[package]]
+name = "standard-chunk"
+version = "3.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" },
+]
+
+[[package]]
+name = "standard-sunau"
+version = "3.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "audioop-lts", marker = "python_full_version >= '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload-time = "2024-10-30T16:01:28.003Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "0.52.1"
+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/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
+[[package]]
+name = "threadpoolctl"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
+]
+
+[[package]]
+name = "tiktoken"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "regex" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
+ { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
+ { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
+ { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
+ { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
+ { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
+ { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
+ { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
+ { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
+ { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
+ { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
+ { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
+ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.10.0+cu130"
+source = { registry = "https://download.pytorch.org/whl/cu130" }
+dependencies = [
+ { name = "cuda-bindings", marker = "sys_platform == 'linux'" },
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "jinja2" },
+ { name = "networkx" },
+ { name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cufft", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-curand", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" },
+ { name = "setuptools" },
+ { name = "sympy" },
+ { name = "triton", marker = "sys_platform == 'linux'" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4fc8f67637f4c92b989a07d80ffe755e79a3510ca02ebf23ce66396fb277c88d" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:858f0cbcc78d726fea9499eb3464faa98392fa093845a3262209bd226b7844d6" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:224649fa0ab181ec483cc368e3303dda1760e4ba31bea806b88979f855436aaa" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:75780283308df9fede371eeda01e9607c8862a1803a2f2f31a08a2c0deaed342" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:7e0d9922e9e91f780b2761a0c5ebac3c15c9740bab042e1b59149afa6d6474eb" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:48af94af745a9dd9b42be81ea15b56aba981666bcfe10394dceca6d9476a50fa" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:46699da91f0367d8dfa1b606cb0352aaf190b5853f463010e75ff08f15a94e7d" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:775d1fff07e302fb669d555a5005f781aa460aa80dff7a512e8e6e723f9def83" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp313-cp313t-win_amd64.whl", hash = "sha256:b38e5b505b015903a51c2b3f12e50a9f152f92fe7e3992e79f504138cf90601d" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:18f87ae628c02f095f2e97756e4fa249ceef6ed6e87d5a3c79b5338abf842511" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:db5a61791b7da3c1aa5a496e64cd72dbd4ef3ef2cbb69680fd45dc255b0da2f3" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp314-cp314-win_amd64.whl", hash = "sha256:dda35d473dd34cafa0668be176b9ad2cb69b1ff570d0336715a6541e89e27640" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7781235583ea06b214075c10fa95f83b9805f06af44efc6e9946808413cff94f" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:526b737db11d632281795484ec729baae5f193a5a0d76a1f7d822f7897c8b4f5" },
+ { url = "https://download.pytorch.org/whl/cu130/torch-2.10.0%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:d5ea18790a18b660d655f6e75a8ca6e8d6298b55fc338f8c921764b94c886743" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "transformers"
+version = "5.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "regex" },
+ { name = "safetensors" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+ { name = "typer-slim" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/7e/8a0c57d562015e5b16c97c1f0b8e0e92ead2c7c20513225dc12c2043ba9f/transformers-5.2.0.tar.gz", hash = "sha256:0088b8b46ccc9eff1a1dca72b5d618a5ee3b1befc3e418c9512b35dea9f9a650", size = 8618176, upload-time = "2026-02-16T18:54:02.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/93/79754b0ca486e556c2b95d4f5afc66aaf4b260694f3d6e1b51da2d036691/transformers-5.2.0-py3-none-any.whl", hash = "sha256:9ecaf243dc45bee11a7d93f8caf03746accc0cb069181bbf4ad8566c53e854b4", size = 10403304, upload-time = "2026-02-16T18:53:59.699Z" },
+]
+
+[[package]]
+name = "triton"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" },
+ { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" },
+ { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
+]
+
+[[package]]
+name = "ty"
+version = "0.0.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
+ { url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
+ { url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
+ { url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" },
+]
+
+[[package]]
+name = "typer-slim"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/22/b9c47b8655937b6877d40791b937931702ba9c5f9d28753199266aa96f50/typer_slim-0.23.1.tar.gz", hash = "sha256:dfe92a6317030ee2380f65bf92e540d7c77fefcc689e10d585b4925b45b5e06a", size = 4762, upload-time = "2026-02-13T10:04:26.416Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/8a/5764b851659345f34787f1b6eb30b9d308bbd6c294825cbe38b6b869c97a/typer_slim-0.23.1-py3-none-any.whl", hash = "sha256:8146d5df1eb89f628191c4c604c8464fa841885d0733c58e6e700ff0228adac5", size = 3397, upload-time = "2026-02-13T10:04:27.132Z" },
+]
+
+[[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.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[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.optional-dependencies]
+standard = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "httptools" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
+ { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
+ { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
+ { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
+ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
+ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
+ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
+ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
+ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
+ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
+ { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
+ { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
+ { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
+ { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
+ { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
+ { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
+ { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
+ { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
+ { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
+ { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
+ { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
+ { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
+ { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
+ { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
+]
+
+[[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" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
+ { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
+ { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
+ { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
+ { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
+ { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
+ { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
+ { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
+ { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
+ { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
+ { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
+ { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
+ { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
+ { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
+ { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
+ { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
+ { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
+ { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
+ { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
+ { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
+ { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
+ { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
+ { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
+ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
+]